From 67801cc52a42e7080b859fbb7adc10cfc2704238 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 3 Sep 2026 07:53:11 -0600 Subject: [PATCH] Add DInput and Mac generic HID modes --- CMakeLists.txt | 1 + adapter_host_probe.cpp | 38 +- adapter_mode_controller.cpp | 28 +- adapter_usb_mode.h | 2 + generic_hid_descriptors.h | 242 +++++++++ generic_hid_driver.cpp | 81 +++ generic_hid_driver.h | 16 + src/switch_pico_bridge/config_manager.py | 54 +- tests/adapter_host_probe_test.cpp | 20 +- tests/adapter_mode_controller_test.cpp | 67 ++- tests/configuration_service_test.cpp | 7 +- tests/generic_hid_descriptors_test.cpp | 548 ++++++++++++++++++++ tests/generic_hid_driver_test.cpp | 298 +++++++++++ tests/test_config_manager.py | 166 +++++- tests/test_generic_hid_native.py | 108 ++++ tests/test_usb_output_driver_native.py | 1 + tests/usb_configuration_management_test.cpp | 46 +- tests/usb_output_driver_test.cpp | 148 ++++++ tusb_config.h | 6 +- usb_configuration_management.cpp | 2 +- usb_output_driver.cpp | 112 +++- usb_output_driver.h | 10 + 22 files changed, 1926 insertions(+), 75 deletions(-) create mode 100644 generic_hid_descriptors.h create mode 100644 generic_hid_driver.cpp create mode 100644 generic_hid_driver.h create mode 100644 tests/generic_hid_descriptors_test.cpp create mode 100644 tests/generic_hid_driver_test.cpp create mode 100644 tests/test_generic_hid_native.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 7c8e944..961bb33 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -118,6 +118,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") pico_configuration_storage.cpp usb_configuration_management.cpp xinput_driver.cpp + generic_hid_driver.cpp ) target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_BLUEPAD32=1 diff --git a/adapter_host_probe.cpp b/adapter_host_probe.cpp index deb2c03..438b124 100644 --- a/adapter_host_probe.cpp +++ b/adapter_host_probe.cpp @@ -54,26 +54,40 @@ void adapter_host_probe_init(AdapterRequestedMode requested_mode) { case AdapterRequestedMode::kXInput: g_mode = AdapterUsbMode::kXInput; break; + case AdapterRequestedMode::kDInput: + g_mode = AdapterUsbMode::kDInput; + break; + case AdapterRequestedMode::kMac: + g_mode = AdapterUsbMode::kMac; + break; case AdapterRequestedMode::kAuto: g_mode = scratch == kXInputBootMagic ? AdapterUsbMode::kXInput : AdapterUsbMode::kSwitchProbe; break; - 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" - : (g_mode == AdapterUsbMode::kSwitch - ? "Switch" - : "Switch probe")); +#ifdef SWITCH_PICO_LOG + const char* mode_name = "Switch probe"; + switch (g_mode) { + case AdapterUsbMode::kSwitch: + mode_name = "Switch"; + break; + case AdapterUsbMode::kSwitchProbe: + break; + case AdapterUsbMode::kXInput: + mode_name = "XInput"; + break; + case AdapterUsbMode::kDInput: + mode_name = "DInput"; + break; + case AdapterUsbMode::kMac: + mode_name = "Mac"; + break; + } + PROBE_LOG("[HOST PROBE] boot mode=%s\n", mode_name); +#endif } AdapterUsbMode adapter_host_probe_mode() { return g_mode; } diff --git a/adapter_mode_controller.cpp b/adapter_mode_controller.cpp index b978c5c..5d04dd4 100644 --- a/adapter_mode_controller.cpp +++ b/adapter_mode_controller.cpp @@ -110,14 +110,17 @@ AdapterRequestedMode next_mode(AdapterRequestedMode mode) { case AdapterRequestedMode::kSwitch: return AdapterRequestedMode::kXInput; case AdapterRequestedMode::kXInput: + return AdapterRequestedMode::kDInput; case AdapterRequestedMode::kDInput: + return AdapterRequestedMode::kMac; case AdapterRequestedMode::kMac: return AdapterRequestedMode::kAuto; } return AdapterRequestedMode::kAuto; } -uint8_t feedback_pulse_count(AdapterRequestedMode mode) { +// Profile numbers select both a bounded pulse count and the existing color. +uint8_t feedback_profile_number(AdapterRequestedMode mode) { switch (mode) { case AdapterRequestedMode::kAuto: return 1; @@ -127,11 +130,20 @@ uint8_t feedback_pulse_count(AdapterRequestedMode mode) { return 3; case AdapterRequestedMode::kDInput: case AdapterRequestedMode::kMac: - return 1; + return 4; } return 1; } +// Mac reuses the fourth bounded pulse/color identity but is LED-only, making +// its confirmation tuple distinct from DInput without exceeding profile bounds. +ControllerProfileConfirmationPolicy feedback_policy( + AdapterRequestedMode mode) { + return mode == AdapterRequestedMode::kMac + ? ControllerProfileConfirmationPolicy::kLed + : ControllerProfileConfirmationPolicy::kRumbleAndLed; +} + void clear_mode_chord(ControllerState* state) { if (state == nullptr) { return; @@ -210,12 +222,14 @@ void finish_successful_mode_write(uint32_t now_ms) { } controller_profile_runtime_reset(); - const uint8_t pulses = feedback_pulse_count(g_operation.target_mode); + const uint8_t feedback_profile = + feedback_profile_number(g_operation.target_mode); bluepad32_input_backend_queue_profile_feedback( - g_operation.slot, g_operation.connection_generation, pulses, - ControllerProfileConfirmationPolicy::kRumbleAndLed); + g_operation.slot, g_operation.connection_generation, + feedback_profile, feedback_policy(g_operation.target_mode)); g_operation.feedback_deadline_ms = - now_ms + static_cast(pulses) * 2u * kFeedbackPhaseMs + + now_ms + static_cast(feedback_profile) * 2u * + kFeedbackPhaseMs + kFeedbackGuardMs; g_operation.phase = ModeOperationPhase::kAcknowledge; } @@ -268,7 +282,7 @@ void advance_mode_write(uint32_t now_ms) { const AdapterModeAvailability& adapter_usb_mode_availability() { static constexpr AdapterModeAvailability kAvailability{ - true, true, false, false}; + true, true, true, true}; return kAvailability; } diff --git a/adapter_usb_mode.h b/adapter_usb_mode.h index 55a4415..d5b5ed7 100644 --- a/adapter_usb_mode.h +++ b/adapter_usb_mode.h @@ -20,6 +20,8 @@ enum class AdapterUsbMode : uint8_t { kSwitch = 0, kSwitchProbe = 1, kXInput = 2, + kDInput = 3, + kMac = 4, }; // Availability of USB mode implementations in this firmware build. All mode diff --git a/generic_hid_descriptors.h b/generic_hid_descriptors.h new file mode 100644 index 0000000..b742a5c --- /dev/null +++ b/generic_hid_descriptors.h @@ -0,0 +1,242 @@ +#pragma once + +#include +#include + +#include "controller_state.h" + +#ifndef SWITCH_PICO_HID_INSTANCE_COUNT +#define SWITCH_PICO_HID_INSTANCE_COUNT 1 +#endif + +#if SWITCH_PICO_HID_INSTANCE_COUNT < 1 || SWITCH_PICO_HID_INSTANCE_COUNT > 4 +#error "SWITCH_PICO_HID_INSTANCE_COUNT must be between 1 and 4" +#endif + +namespace GenericHid { + +constexpr uint16_t kDevelopmentVendorId = 0xcafe; +constexpr uint16_t kDInputDevelopmentProductId = 0x4020; +constexpr uint16_t kMacDevelopmentProductId = 0x4021; +constexpr uint16_t kDevelopmentDeviceRevision = 0x0100; + +constexpr uint8_t kReportSize = 15; +constexpr uint8_t kEndpointSize = kReportSize; +constexpr uint8_t kEndpointIntervalMs = 1; +constexpr uint8_t kInterfaceDescriptorSize = 25; +constexpr uint16_t kConfigurationDescriptorSize = + 9 + SWITCH_PICO_HID_INSTANCE_COUNT * kInterfaceDescriptorSize; + +constexpr uint8_t kHatUp = 0; +constexpr uint8_t kHatUpRight = 1; +constexpr uint8_t kHatRight = 2; +constexpr uint8_t kHatDownRight = 3; +constexpr uint8_t kHatDown = 4; +constexpr uint8_t kHatDownLeft = 5; +constexpr uint8_t kHatLeft = 6; +constexpr uint8_t kHatUpLeft = 7; +constexpr uint8_t kHatCenter = 8; + +constexpr uint16_t kButtonSouth = 1u << 0u; +constexpr uint16_t kButtonEast = 1u << 1u; +constexpr uint16_t kButtonWest = 1u << 2u; +constexpr uint16_t kButtonNorth = 1u << 3u; +constexpr uint16_t kButtonLeftShoulder = 1u << 4u; +constexpr uint16_t kButtonRightShoulder = 1u << 5u; +constexpr uint16_t kButtonSelect = 1u << 6u; +constexpr uint16_t kButtonStart = 1u << 7u; +constexpr uint16_t kButtonLeftStick = 1u << 8u; +constexpr uint16_t kButtonRightStick = 1u << 9u; +constexpr uint16_t kButtonSystem = 1u << 10u; +constexpr uint16_t kButtonCapture = 1u << 11u; +constexpr uint16_t kDefinedButtonMask = 0x0fffu; + +struct InputReport { + uint8_t data[kReportSize]; +}; + +static_assert(sizeof(InputReport) == kReportSize); + +constexpr void write_u16_le(uint8_t* destination, uint16_t value) { + destination[0] = static_cast(value); + destination[1] = static_cast(value >> 8u); +} + +constexpr uint8_t build_hat(const ControllerState& state) { + const bool up = state.dpad_up && !state.dpad_down; + const bool down = state.dpad_down && !state.dpad_up; + const bool left = state.dpad_left && !state.dpad_right; + const bool right = state.dpad_right && !state.dpad_left; + + if (up) { + if (right) { + return kHatUpRight; + } + if (left) { + return kHatUpLeft; + } + return kHatUp; + } + if (down) { + if (right) { + return kHatDownRight; + } + if (left) { + return kHatDownLeft; + } + return kHatDown; + } + if (right) { + return kHatRight; + } + if (left) { + return kHatLeft; + } + return kHatCenter; +} + +constexpr uint16_t build_buttons(const ControllerState& state) { + return static_cast( + (state.button_south ? kButtonSouth : 0u) | + (state.button_east ? kButtonEast : 0u) | + (state.button_west ? kButtonWest : 0u) | + (state.button_north ? kButtonNorth : 0u) | + (state.button_left_shoulder ? kButtonLeftShoulder : 0u) | + (state.button_right_shoulder ? kButtonRightShoulder : 0u) | + (state.button_select ? kButtonSelect : 0u) | + (state.button_start ? kButtonStart : 0u) | + (state.button_left_stick ? kButtonLeftStick : 0u) | + (state.button_right_stick ? kButtonRightStick : 0u) | + (state.button_system ? kButtonSystem : 0u) | + (state.button_capture ? kButtonCapture : 0u)); +} + +constexpr InputReport build_input_report(const ControllerState& state) { + InputReport report{}; + write_u16_le(report.data + 0, + static_cast(state.left_stick_x)); + write_u16_le(report.data + 2, + static_cast(state.left_stick_y)); + write_u16_le(report.data + 4, + static_cast(state.right_stick_x)); + write_u16_le(report.data + 6, + static_cast(state.right_stick_y)); + write_u16_le(report.data + 8, state.left_trigger); + write_u16_le(report.data + 10, state.right_trigger); + report.data[12] = build_hat(state); + write_u16_le(report.data + 13, build_buttons(state)); + return report; +} + +inline constexpr char kManufacturerString[] = "Switch Pico"; +inline constexpr char kDInputProductString[] = "DInput Development"; +inline constexpr char kDInputSerialString[] = "DINPUT-DEV-4020"; +inline constexpr char kMacProductString[] = "Mac HID Development"; +inline constexpr char kMacSerialString[] = "MAC-HID-DEV-4021"; + +inline constexpr uint8_t kDInputDeviceDescriptor[] = { + 0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40, + static_cast(kDevelopmentVendorId), + static_cast(kDevelopmentVendorId >> 8u), + static_cast(kDInputDevelopmentProductId), + static_cast(kDInputDevelopmentProductId >> 8u), + static_cast(kDevelopmentDeviceRevision), + static_cast(kDevelopmentDeviceRevision >> 8u), + 0x01, 0x02, 0x03, 0x01, +}; + +inline constexpr uint8_t kMacDeviceDescriptor[] = { + 0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40, + static_cast(kDevelopmentVendorId), + static_cast(kDevelopmentVendorId >> 8u), + static_cast(kMacDevelopmentProductId), + static_cast(kMacDevelopmentProductId >> 8u), + static_cast(kDevelopmentDeviceRevision), + static_cast(kDevelopmentDeviceRevision >> 8u), + 0x01, 0x02, 0x03, 0x01, +}; + +// One report per interface, so no Report ID item is needed. +inline constexpr uint8_t kReportDescriptor[] = { + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x05, // Usage (Game Pad) + 0xa1, 0x01, // Collection (Application) + + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x16, 0x00, 0x80, // Logical Minimum (-32768) + 0x26, 0xff, 0x7f, // Logical Maximum (32767) + 0x75, 0x10, // Report Size (16) + 0x95, 0x04, // Report Count (4) + 0x09, 0x30, // Usage (X) + 0x09, 0x31, // Usage (Y) + 0x09, 0x33, // Usage (Rx) + 0x09, 0x34, // Usage (Ry) + 0x81, 0x02, // Input (Data, Variable, Absolute) + + 0x15, 0x00, // Logical Minimum (0) + 0x27, 0xff, 0xff, 0x00, 0x00, // Logical Maximum (65535) + 0x95, 0x02, // Report Count (2) + 0x09, 0x32, // Usage (Z) + 0x09, 0x35, // Usage (Rz) + 0x81, 0x02, // Input (Data, Variable, Absolute) + + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x07, // Logical Maximum (7) + 0x35, 0x00, // Physical Minimum (0) + 0x46, 0x3b, 0x01, // Physical Maximum (315) + 0x65, 0x14, // Unit (English Rotation, Degrees) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x09, 0x39, // Usage (Hat Switch) + 0x81, 0x42, // Input (Data, Variable, Absolute, Null State) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x81, 0x03, // Input (Constant, Variable, Absolute) + + 0x05, 0x09, // Usage Page (Button) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x35, 0x00, // Physical Minimum (0) + 0x45, 0x00, // Physical Maximum (0) + 0x65, 0x00, // Unit (None) + 0x19, 0x01, // Usage Minimum (Button 1) + 0x29, 0x10, // Usage Maximum (Button 16) + 0x75, 0x01, // Report Size (1) + 0x95, 0x10, // Report Count (16) + 0x81, 0x02, // Input (Data, Variable, Absolute) + 0xc0, // End Collection +}; + +#define GENERIC_HID_INTERFACE(number, endpoint) \ + 0x09, 0x04, number, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, \ + 0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, \ + static_cast(sizeof(kReportDescriptor)), \ + static_cast(sizeof(kReportDescriptor) >> 8u), \ + 0x07, 0x05, static_cast(0x80u | endpoint), 0x03, \ + kEndpointSize, 0x00, kEndpointIntervalMs + +inline constexpr uint8_t kConfigurationDescriptor[] = { + 0x09, 0x02, + static_cast(kConfigurationDescriptorSize), + static_cast(kConfigurationDescriptorSize >> 8u), + SWITCH_PICO_HID_INSTANCE_COUNT, + 0x01, 0x00, 0x80, 0xfa, + GENERIC_HID_INTERFACE(0x00, 0x01), +#if SWITCH_PICO_HID_INSTANCE_COUNT >= 2 + GENERIC_HID_INTERFACE(0x01, 0x02), +#endif +#if SWITCH_PICO_HID_INSTANCE_COUNT >= 3 + GENERIC_HID_INTERFACE(0x02, 0x03), +#endif +#if SWITCH_PICO_HID_INSTANCE_COUNT >= 4 + GENERIC_HID_INTERFACE(0x03, 0x04), +#endif +}; + +#undef GENERIC_HID_INTERFACE + +static_assert(sizeof(kDInputDeviceDescriptor) == 18); +static_assert(sizeof(kMacDeviceDescriptor) == 18); +static_assert(sizeof(kConfigurationDescriptor) == kConfigurationDescriptorSize); + +} // namespace GenericHid diff --git a/generic_hid_driver.cpp b/generic_hid_driver.cpp new file mode 100644 index 0000000..530b7e6 --- /dev/null +++ b/generic_hid_driver.cpp @@ -0,0 +1,81 @@ +#include "generic_hid_driver.h" + +#include + +#include "generic_hid_descriptors.h" + +namespace { + +struct GenericHidContext { + ControllerState input{}; + GenericHid::InputReport input_report{}; +}; + +GenericHidContext g_contexts[SWITCH_PICO_HID_INSTANCE_COUNT]{}; + +GenericHidContext* context_for(uint8_t instance) { + if (instance >= SWITCH_PICO_HID_INSTANCE_COUNT) { + return nullptr; + } + return &g_contexts[instance]; +} + + +void reset_context(GenericHidContext& context) { + context = {}; + context.input_report = GenericHid::build_input_report(context.input); +} + +} // namespace + +void generic_hid_init(uint8_t instance) { + GenericHidContext* context = context_for(instance); + if (context != nullptr) { + reset_context(*context); + } +} + +void generic_hid_set_input(uint8_t instance, const ControllerState& state) { + GenericHidContext* context = context_for(instance); + if (context == nullptr) { + return; + } + context->input = state; + context->input_report = GenericHid::build_input_report(state); +} + +bool generic_hid_task(uint8_t instance) { + GenericHidContext* context = context_for(instance); + return context != nullptr && tud_hid_n_ready(instance) && + tud_hid_n_report(instance, 0, &context->input_report, + sizeof(context->input_report)); +} + +bool generic_hid_is_ready(uint8_t instance) { + return context_for(instance) != nullptr && + tud_hid_n_ready(instance); +} + +uint16_t generic_hid_get_report(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t* buffer, + uint16_t requested_length) { + const GenericHidContext* context = context_for(instance); + if (context == nullptr || report_id != 0 || + report_type != HID_REPORT_TYPE_INPUT || buffer == nullptr || + requested_length == 0) { + return 0; + } + + uint16_t report_size = sizeof(context->input_report); + if (requested_length < report_size) { + report_size = requested_length; + } + memcpy(buffer, &context->input_report, report_size); + return report_size; +} + +const uint8_t* generic_hid_report_descriptor(uint8_t instance) { + return context_for(instance) == nullptr + ? nullptr + : GenericHid::kReportDescriptor; +} diff --git a/generic_hid_driver.h b/generic_hid_driver.h new file mode 100644 index 0000000..d68274d --- /dev/null +++ b/generic_hid_driver.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +#include "controller_state.h" +#include "tusb.h" + +void generic_hid_init(uint8_t instance); +void generic_hid_set_input(uint8_t instance, const ControllerState& state); +bool generic_hid_task(uint8_t instance); +bool generic_hid_is_ready(uint8_t instance); + +uint16_t generic_hid_get_report(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t* buffer, + uint16_t requested_length); +const uint8_t* generic_hid_report_descriptor(uint8_t instance); diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 95f9c14..cbe4405 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -17,7 +17,12 @@ from typing import Any, Protocol, cast import usb.core -USB_IDENTITIES = ((0x057E, 0x2009), (0xCAFE, 0x4010)) +USB_IDENTITIES = ( + (0x057E, 0x2009), + (0xCAFE, 0x4010), + (0xCAFE, 0x4020), + (0xCAFE, 0x4021), +) REQUEST_VALUE = 0x5350 REQUEST_INDEX = 0x0001 PROTOCOL_VERSION = 1 @@ -74,11 +79,18 @@ 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] +SELECTABLE_MODE_NAMES = REQUESTED_MODE_NAMES ACTIVE_MODE_SWITCH = 0 ACTIVE_MODE_SWITCH_PROBE = 1 ACTIVE_MODE_XINPUT = 2 -ACTIVE_MODE_NAMES = ("Switch", "Switch probe", "XInput") +ACTIVE_MODE_DINPUT = 3 +ACTIVE_MODE_MAC = 4 +ACTIVE_MODE_NAMES = ("Switch", "Switch probe", "XInput", "DInput", "Mac") +# USB management info byte 5 capability flags. +CAPABILITY_INPUT = 1 << 0 +CAPABILITY_RUMBLE = 1 << 1 +CAPABILITY_MOTION = 1 << 2 +CAPABILITY_MASK = CAPABILITY_INPUT | CAPABILITY_RUMBLE | CAPABILITY_MOTION PAIRING_RECORD_SIZE = 8 PAIRING_RECORD_CAPACITY = 16 TRANSPORT_UNKNOWN = 0 @@ -167,6 +179,7 @@ class DeviceInfo: firmware_version: tuple[int, int, int] board: int active_mode: int + capabilities: int maximum_configuration_size: int def mode_name(self) -> str: @@ -177,6 +190,20 @@ class DeviceInfo: f"unknown active USB mode {self.active_mode}" ) from exc + def capability_names(self) -> tuple[str, ...]: + if self.capabilities == 0: + return ("unreported",) + names = ["input"] + if self.capabilities & CAPABILITY_RUMBLE: + names.append("rumble") + if self.capabilities & CAPABILITY_MOTION: + names.append("motion") + return tuple(names) + + def capability_summary(self) -> str: + names = self.capability_names() + return "input only" if names == ("input",) else ", ".join(names) + @dataclass(frozen=True) class AdapterConfiguration: @@ -1295,11 +1322,20 @@ def _control_out( def read_info(device: UsbDevice) -> DeviceInfo: envelope = _control_in(device, OP_INFO) _raise_status(envelope) - if len(envelope.payload) != 8 or envelope.payload[5] != 0: + if len(envelope.payload) != 8: raise ConfigManagerError("invalid device-info payload") active_mode = envelope.payload[4] + capabilities = envelope.payload[5] if active_mode >= len(ACTIVE_MODE_NAMES): raise ConfigManagerError(f"unknown active USB mode {active_mode}") + if capabilities & ~CAPABILITY_MASK: + raise ConfigManagerError( + f"unknown device capability flags 0x{capabilities:02x}" + ) + if capabilities != 0 and not capabilities & CAPABILITY_INPUT: + raise ConfigManagerError( + "device capability flags omit required input support" + ) return DeviceInfo( firmware_version=( envelope.payload[0], @@ -1308,6 +1344,7 @@ def read_info(device: UsbDevice) -> DeviceInfo: ), board=envelope.payload[3], active_mode=active_mode, + capabilities=capabilities, maximum_configuration_size=struct.unpack_from( " bool: return active_mode == ACTIVE_MODE_SWITCH if requested_mode == REQUESTED_MODE_XINPUT: return active_mode == ACTIVE_MODE_XINPUT + if requested_mode == REQUESTED_MODE_DINPUT: + return active_mode == ACTIVE_MODE_DINPUT + if requested_mode == REQUESTED_MODE_MAC: + return active_mode == ACTIVE_MODE_MAC return False @@ -1948,6 +1991,8 @@ def configure_mode( REQUESTED_MODE_AUTO, REQUESTED_MODE_SWITCH, REQUESTED_MODE_XINPUT, + REQUESTED_MODE_DINPUT, + REQUESTED_MODE_MAC, ): raise ConfigManagerError("requested USB mode is not available") before_info = read_info(device) @@ -2204,6 +2249,7 @@ def main(argv: Sequence[str] | None = None) -> int: f"{REQUESTED_MODE_NAMES[configuration.requested_mode]}" ) print(f"Active USB mode: {info.mode_name()}") + print(f"Mode capabilities: {info.capability_summary()}") print(f"Configuration generation: {configuration.generation}") print(f"Configuration CRC: {configuration.crc:08x}") print( diff --git a/tests/adapter_host_probe_test.cpp b/tests/adapter_host_probe_test.cpp index a8af541..b346111 100644 --- a/tests/adapter_host_probe_test.cpp +++ b/tests/adapter_host_probe_test.cpp @@ -118,9 +118,25 @@ void test_manual_modes_bypass_and_consume_probe_state() { reset_harness(kXInputBootMagic); adapter_host_probe_init(AdapterRequestedMode::kDInput); - require(adapter_host_probe_mode() == AdapterUsbMode::kSwitch && + require(adapter_host_probe_mode() == AdapterUsbMode::kDInput && watchdog_registers.scratch[0] == 0 && alarm_count == 0, - "unavailable DInput was selected or treated as Auto"); + "manual DInput did not bypass and consume stale auto scratch"); + adapter_host_probe_note_string_descriptor(0xee); + require(!adapter_host_probe_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_count == 0 && alarm_count == 0, + "manual DInput entered the XInput probe path"); + + reset_harness(kXInputBootMagic); + adapter_host_probe_init(AdapterRequestedMode::kMac); + require(adapter_host_probe_mode() == AdapterUsbMode::kMac && + watchdog_registers.scratch[0] == 0 && alarm_count == 0, + "manual Mac did not bypass and consume stale auto scratch"); + adapter_host_probe_note_string_descriptor(0xee); + require(!adapter_host_probe_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_count == 0 && alarm_count == 0, + "manual Mac entered the XInput probe path"); } void test_probe_transition_resets_before_watchdog() { diff --git a/tests/adapter_mode_controller_test.cpp b/tests/adapter_mode_controller_test.cpp index fcc4971..e6fb36e 100644 --- a/tests/adapter_mode_controller_test.cpp +++ b/tests/adapter_mode_controller_test.cpp @@ -160,9 +160,9 @@ void test_mode_availability_has_one_stable_value() { 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"); + first.xinput_mode && first.dinput_mode && + first.mac_mode, + "mode availability was not the shared five-mode value"); } void test_exact_hold_release_wrap_and_consumption() { @@ -226,11 +226,14 @@ void test_cycle_and_slot_isolation() { AdapterRequestedMode::kXInput, "Switch did not cycle to XInput"); require(triggered_target(AdapterRequestedMode::kXInput) == - AdapterRequestedMode::kAuto, - "XInput did not cycle to Auto"); + AdapterRequestedMode::kDInput, + "XInput did not cycle to DInput"); require(triggered_target(AdapterRequestedMode::kDInput) == + AdapterRequestedMode::kMac, + "DInput did not cycle to Mac"); + require(triggered_target(AdapterRequestedMode::kMac) == AdapterRequestedMode::kAuto, - "unimplemented configured value entered the chord cycle"); + "Mac did not cycle to Auto"); reset_harness(); begin_hold(0, 10, 0); @@ -328,6 +331,53 @@ void test_commit_feedback_then_reboot() { "scheduled watchdog reboot looped"); } +void test_feedback_distinguishes_all_modes() { + struct FeedbackCase { + AdapterRequestedMode initial_mode; + AdapterRequestedMode target_mode; + uint8_t pulses; + ControllerProfileConfirmationPolicy policy; + }; + constexpr FeedbackCase cases[] = { + {AdapterRequestedMode::kAuto, AdapterRequestedMode::kSwitch, 2, + ControllerProfileConfirmationPolicy::kRumbleAndLed}, + {AdapterRequestedMode::kSwitch, AdapterRequestedMode::kXInput, 3, + ControllerProfileConfirmationPolicy::kRumbleAndLed}, + {AdapterRequestedMode::kXInput, AdapterRequestedMode::kDInput, 4, + ControllerProfileConfirmationPolicy::kRumbleAndLed}, + {AdapterRequestedMode::kDInput, AdapterRequestedMode::kMac, 4, + ControllerProfileConfirmationPolicy::kLed}, + {AdapterRequestedMode::kMac, AdapterRequestedMode::kAuto, 1, + ControllerProfileConfirmationPolicy::kRumbleAndLed}, + }; + + for (const FeedbackCase& expected : cases) { + reset_harness(expected.initial_mode); + begin_hold(1, 23, 0); + finish_hold(1, 23, 0); + set_results = {ConfigurationTransactionStatus::kCommitted}; + adapter_mode_controller_task(ADAPTER_MODE_CHORD_HOLD_MS); + require(set_modes.size() == 1 && + set_modes[0] == expected.target_mode && + feedback_slot == 1 && feedback_generation == 23 && + feedback_pulses == expected.pulses && + feedback_policy == expected.policy && + reboot_count == 0, + "mode feedback tuple did not uniquely identify its target"); + + const uint32_t feedback_duration = + static_cast(expected.pulses) * 2u * 75u + 75u; + adapter_mode_controller_task( + ADAPTER_MODE_CHORD_HOLD_MS + feedback_duration - 1u); + require(reboot_count == 0, + "mode feedback deadline was shorter than its bounded pulse sequence"); + adapter_mode_controller_task( + ADAPTER_MODE_CHORD_HOLD_MS + feedback_duration); + require(reboot_count == 1, + "mode feedback did not reboot at its bounded deadline"); + } +} + void test_configuration_failure_and_correlated_reboot() { reset_harness(); begin_hold(0, 1, 0); @@ -512,8 +562,8 @@ ConfigurationTransactionStatus configuration_service_set_mode_internal( "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"); + availability.dinput_mode && availability.mac_mode, + "mode controller did not advertise all compiled drivers"); set_transaction_ids.push_back(transaction_id); set_modes.push_back(requested_mode); if (next_set_result >= set_results.size()) { @@ -605,6 +655,7 @@ int main() { test_cycle_and_slot_isolation(); test_busy_retry_and_one_shot(); test_commit_feedback_then_reboot(); + test_feedback_distinguishes_all_modes(); test_configuration_failure_and_correlated_reboot(); test_recovery_auto_wins_host_mode_interleaving(); test_failed_recovery_never_reboots(); diff --git a/tests/configuration_service_test.cpp b/tests/configuration_service_test.cpp index 5ca525a..557bf55 100644 --- a/tests/configuration_service_test.cpp +++ b/tests/configuration_service_test.cpp @@ -131,7 +131,7 @@ void test_service_lifecycle_and_mutations() { g_flash.erase_count == erases_after_seed, "pre-USB initialization wrote flash"); - const AdapterModeAvailability implemented{}; + const AdapterModeAvailability implemented{true, true, true, true}; require(configuration_service_set_mode( 1, AdapterRequestedMode::kSwitch, implemented) == ConfigurationTransactionStatus::kBusy, @@ -200,8 +200,9 @@ void test_service_lifecycle_and_mutations() { "mode-only commit changed pairing state or host status"); const int programs_before_rejections = g_flash.program_count; + const AdapterModeAvailability unavailable{true, true, false, false}; require(configuration_service_set_mode( - 11, AdapterRequestedMode::kDInput, implemented) == + 11, AdapterRequestedMode::kDInput, unavailable) == ConfigurationTransactionStatus::kUnsupportedSchema && configuration_service_set_mode( 11, static_cast(5), implemented) == @@ -493,7 +494,7 @@ void test_abandoned_host_receive_does_not_block_recovery() { ConfigurationTransactionStatus::kBusy, "recovery reservation did not terminally cancel host receive"); - const AdapterModeAvailability implemented{}; + const AdapterModeAvailability implemented{true, true, true, true}; constexpr uint32_t kRecoveryAuto = 0x80000040u; require(configuration_service_set_mode_internal( kRecoveryAuto, AdapterRequestedMode::kAuto, diff --git a/tests/generic_hid_descriptors_test.cpp b/tests/generic_hid_descriptors_test.cpp new file mode 100644 index 0000000..f46d722 --- /dev/null +++ b/tests/generic_hid_descriptors_test.cpp @@ -0,0 +1,548 @@ +#include "generic_hid_descriptors.h" + +#include +#include +#include +#include +#include +#include + +#ifndef EXPECTED_HID_INSTANCE_COUNT +#error "EXPECTED_HID_INSTANCE_COUNT must be defined by the test build" +#endif + +static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == EXPECTED_HID_INSTANCE_COUNT); +static_assert(sizeof(GenericHid::kConfigurationDescriptor) == + 9u + 25u * EXPECTED_HID_INSTANCE_COUNT); +static_assert(sizeof(GenericHid::InputReport) == 15); + +namespace { + +int failures = 0; + +void expect(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + ++failures; + } +} + +uint16_t read_u16(const uint8_t* bytes) { + return static_cast(bytes[0]) | + static_cast(static_cast(bytes[1]) << 8u); +} + +int16_t read_i16(const uint8_t* bytes) { + return static_cast(read_u16(bytes)); +} + +struct ItemGolden { + uint8_t type; + uint8_t tag; + uint8_t size; + uint32_t value; +}; + +constexpr std::array kReportItemGolden{{ + {1, 0, 1, 0x01}, {2, 0, 1, 0x05}, {0, 10, 1, 0x01}, + {1, 0, 1, 0x01}, {1, 1, 2, 0x8000}, {1, 2, 2, 0x7fff}, + {1, 7, 1, 0x10}, {1, 9, 1, 0x04}, {2, 0, 1, 0x30}, + {2, 0, 1, 0x31}, {2, 0, 1, 0x33}, {2, 0, 1, 0x34}, + {0, 8, 1, 0x02}, {1, 1, 1, 0x00}, {1, 2, 4, 0xffff}, + {1, 9, 1, 0x02}, {2, 0, 1, 0x32}, {2, 0, 1, 0x35}, + {0, 8, 1, 0x02}, {1, 1, 1, 0x00}, {1, 2, 1, 0x07}, + {1, 3, 1, 0x00}, {1, 4, 2, 0x013b}, {1, 6, 1, 0x14}, + {1, 7, 1, 0x04}, {1, 9, 1, 0x01}, {2, 0, 1, 0x39}, + {0, 8, 1, 0x42}, {1, 7, 1, 0x04}, {1, 9, 1, 0x01}, + {0, 8, 1, 0x03}, {1, 0, 1, 0x09}, {1, 1, 1, 0x00}, + {1, 2, 1, 0x01}, {1, 3, 1, 0x00}, {1, 4, 1, 0x00}, + {1, 6, 1, 0x00}, {2, 1, 1, 0x01}, {2, 2, 1, 0x10}, + {1, 7, 1, 0x01}, {1, 9, 1, 0x10}, {0, 8, 1, 0x02}, + {0, 12, 0, 0x00}, +}}; + +int32_t sign_extend(uint32_t value, uint8_t size) { + if (size == 4 || size == 0) { + return static_cast(value); + } + const uint8_t bits = static_cast(size * 8u); + const uint32_t sign = 1u << (bits - 1u); + return static_cast((value ^ sign) - sign); +} + +struct GlobalState { + uint32_t usage_page = 0; + int32_t logical_minimum = 0; + int64_t logical_maximum = 0; + int32_t physical_minimum = 0; + int64_t physical_maximum = 0; + uint32_t unit = 0; + uint32_t report_size = 0; + uint32_t report_count = 0; +}; + +struct LocalState { + std::array usages{}; + uint8_t usage_count = 0; + uint32_t usage_minimum = 0; + uint32_t usage_maximum = 0; + bool has_usage_range = false; + + void clear() { + *this = {}; + } +}; + +struct InputField { + uint16_t bit_offset; + uint8_t size; + uint8_t count; + uint8_t flags; + GlobalState globals; + LocalState locals; +}; + +void inspect_report_descriptor() { + const uint8_t* descriptor = GenericHid::kReportDescriptor; + const size_t descriptor_size = sizeof(GenericHid::kReportDescriptor); + size_t offset = 0; + size_t decoded = 0; + uint16_t report_bits = 0; + uint8_t collection_depth = 0; + bool gamepad_application = false; + bool saw_report_id = false; + bool saw_output = false; + bool saw_feature = false; + GlobalState globals{}; + LocalState locals{}; + std::vector fields; + + while (offset < descriptor_size) { + const uint8_t prefix = descriptor[offset++]; + expect(prefix != 0xfe, "long HID item is not part of the golden contract"); + if (prefix == 0xfe) { + break; + } + uint8_t size = prefix & 0x03u; + if (size == 3) { + size = 4; + } + expect(offset + size <= descriptor_size, + "HID item extends beyond the report descriptor"); + if (offset + size > descriptor_size) { + break; + } + uint32_t value = 0; + for (uint8_t byte = 0; byte < size; ++byte) { + value |= static_cast(descriptor[offset + byte]) << + (8u * byte); + } + offset += size; + const uint8_t type = static_cast((prefix >> 2u) & 0x03u); + const uint8_t tag = static_cast(prefix >> 4u); + + expect(decoded < kReportItemGolden.size(), + "report descriptor contains an extra HID item"); + if (decoded < kReportItemGolden.size()) { + const ItemGolden& golden = kReportItemGolden[decoded]; + expect(type == golden.type && tag == golden.tag && + size == golden.size && value == golden.value, + "decoded HID item differs from the golden contract"); + } + ++decoded; + + if (type == 1) { + switch (tag) { + case 0: + globals.usage_page = value; + break; + case 1: + globals.logical_minimum = sign_extend(value, size); + break; + case 2: + globals.logical_maximum = globals.logical_minimum < 0 + ? sign_extend(value, size) + : value; + break; + case 3: + globals.physical_minimum = sign_extend(value, size); + break; + case 4: + globals.physical_maximum = globals.physical_minimum < 0 + ? sign_extend(value, size) + : value; + break; + case 6: + globals.unit = value; + break; + case 7: + globals.report_size = value; + break; + case 8: + saw_report_id = true; + break; + case 9: + globals.report_count = value; + break; + default: + expect(false, "unexpected global HID item"); + break; + } + continue; + } + if (type == 2) { + if (tag == 0) { + expect(locals.usage_count < locals.usages.size(), + "too many local usages in the report descriptor"); + if (locals.usage_count < locals.usages.size()) { + locals.usages[locals.usage_count++] = value; + } + } else if (tag == 1) { + locals.usage_minimum = value; + locals.has_usage_range = true; + } else if (tag == 2) { + locals.usage_maximum = value; + locals.has_usage_range = true; + } else { + expect(false, "unexpected local HID item"); + } + continue; + } + + expect(type == 0, "reserved HID item type is present"); + if (type != 0) { + continue; + } + if (tag == 10) { + gamepad_application = collection_depth == 0 && value == 1 && + globals.usage_page == 1 && + locals.usage_count == 1 && + locals.usages[0] == 5; + ++collection_depth; + } else if (tag == 12) { + expect(collection_depth > 0, "unbalanced End Collection item"); + if (collection_depth > 0) { + --collection_depth; + } + } else if (tag == 8) { + fields.push_back(InputField{ + report_bits, + static_cast(globals.report_size), + static_cast(globals.report_count), + static_cast(value), + globals, + locals, + }); + report_bits = static_cast( + report_bits + globals.report_size * globals.report_count); + } else if (tag == 9) { + saw_output = true; + } else if (tag == 11) { + saw_feature = true; + } else { + expect(false, "unexpected main HID item"); + } + locals.clear(); + } + + expect(offset == descriptor_size, "report descriptor was not fully decoded"); + expect(decoded == kReportItemGolden.size(), + "report descriptor is missing a golden HID item"); + expect(gamepad_application && collection_depth == 0, + "report is not one balanced Game Pad application collection"); + expect(!saw_report_id, "single-interface report unexpectedly has a Report ID"); + expect(!saw_output && !saw_feature, + "input-only generic HID descriptor declares output or feature data"); + expect(report_bits == GenericHid::kReportSize * 8u, + "input report does not contain exactly 15 bytes"); + expect(fields.size() == 5, "input report has the wrong field count"); + if (fields.size() != 5) { + return; + } + + const InputField& sticks = fields[0]; + expect(sticks.bit_offset == 0 && sticks.size == 16 && sticks.count == 4 && + sticks.flags == 0x02 && sticks.globals.usage_page == 1 && + sticks.globals.logical_minimum == -32768 && + sticks.globals.logical_maximum == 32767 && + sticks.locals.usage_count == 4 && + sticks.locals.usages[0] == 0x30 && + sticks.locals.usages[1] == 0x31 && + sticks.locals.usages[2] == 0x33 && + sticks.locals.usages[3] == 0x34, + "signed X/Y/Rx/Ry field layout is wrong"); + + const InputField& triggers = fields[1]; + expect(triggers.bit_offset == 64 && triggers.size == 16 && + triggers.count == 2 && triggers.flags == 0x02 && + triggers.globals.usage_page == 1 && + triggers.globals.logical_minimum == 0 && + triggers.globals.logical_maximum == 65535 && + triggers.locals.usage_count == 2 && + triggers.locals.usages[0] == 0x32 && + triggers.locals.usages[1] == 0x35, + "unsigned Z/Rz trigger field layout is wrong"); + + const InputField& hat = fields[2]; + expect(hat.bit_offset == 96 && hat.size == 4 && hat.count == 1 && + hat.flags == 0x42 && hat.globals.usage_page == 1 && + hat.globals.logical_minimum == 0 && + hat.globals.logical_maximum == 7 && + hat.globals.physical_minimum == 0 && + hat.globals.physical_maximum == 315 && + hat.globals.unit == 0x14 && hat.locals.usage_count == 1 && + hat.locals.usages[0] == 0x39, + "Hat Switch field or declared null-state semantics are wrong"); + + const InputField& padding = fields[3]; + expect(padding.bit_offset == 100 && padding.size == 4 && + padding.count == 1 && padding.flags == 0x03 && + padding.locals.usage_count == 0 && + !padding.locals.has_usage_range, + "hat padding is not four constant bits"); + + const InputField& buttons = fields[4]; + expect(buttons.bit_offset == 104 && buttons.size == 1 && + buttons.count == 16 && buttons.flags == 0x02 && + buttons.globals.usage_page == 9 && + buttons.globals.logical_minimum == 0 && + buttons.globals.logical_maximum == 1 && + buttons.globals.unit == 0 && buttons.locals.has_usage_range && + buttons.locals.usage_minimum == 1 && + buttons.locals.usage_maximum == 16, + "sequential Button 1..16 field layout is wrong"); +} + +void inspect_device_descriptors_and_strings() { + constexpr std::array dinput_golden{{ + 0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40, 0xfe, + 0xca, 0x20, 0x40, 0x00, 0x01, 0x01, 0x02, 0x03, 0x01, + }}; + constexpr std::array mac_golden{{ + 0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40, 0xfe, + 0xca, 0x21, 0x40, 0x00, 0x01, 0x01, 0x02, 0x03, 0x01, + }}; + expect(std::memcmp(GenericHid::kDInputDeviceDescriptor, + dinput_golden.data(), dinput_golden.size()) == 0, + "DInput development device descriptor differs from its golden"); + expect(std::memcmp(GenericHid::kMacDeviceDescriptor, mac_golden.data(), + mac_golden.size()) == 0, + "Mac development device descriptor differs from its golden"); + expect(read_u16(GenericHid::kDInputDeviceDescriptor + 8) == 0xcafe && + read_u16(GenericHid::kDInputDeviceDescriptor + 10) == 0x4020 && + read_u16(GenericHid::kMacDeviceDescriptor + 8) == 0xcafe && + read_u16(GenericHid::kMacDeviceDescriptor + 10) == 0x4021, + "development VID/PIDs are wrong"); + expect(std::strcmp(GenericHid::kManufacturerString, "Switch Pico") == 0 && + std::strcmp(GenericHid::kDInputProductString, + "DInput Development") == 0 && + std::strcmp(GenericHid::kDInputSerialString, + "DINPUT-DEV-4020") == 0 && + std::strcmp(GenericHid::kMacProductString, + "Mac HID Development") == 0 && + std::strcmp(GenericHid::kMacSerialString, + "MAC-HID-DEV-4021") == 0, + "generic HID USB strings differ from their goldens"); + expect(std::strcmp(GenericHid::kDInputProductString, + GenericHid::kMacProductString) != 0 && + std::strcmp(GenericHid::kDInputSerialString, + GenericHid::kMacSerialString) != 0, + "DInput and Mac identities do not have distinct strings"); +} + +void inspect_configuration_descriptor() { + constexpr uint8_t kConfiguration = 0x02; + constexpr uint8_t kInterface = 0x04; + constexpr uint8_t kEndpoint = 0x05; + constexpr uint8_t kHid = 0x21; + const uint8_t* descriptor = GenericHid::kConfigurationDescriptor; + const size_t descriptor_size = sizeof(GenericHid::kConfigurationDescriptor); + + expect(descriptor[0] == 9 && descriptor[1] == kConfiguration && + read_u16(descriptor + 2) == descriptor_size && + descriptor[4] == EXPECTED_HID_INSTANCE_COUNT, + "generic HID configuration header is malformed"); + + std::array interfaces{}; + std::array endpoints{}; + std::array hid_counts{}; + std::array endpoint_counts{}; + int current_interface = -1; + size_t offset = descriptor[0]; + while (offset < descriptor_size) { + const uint8_t length = descriptor[offset]; + expect(length >= 2 && offset + length <= descriptor_size, + "configuration child descriptor has an invalid length"); + if (length < 2 || offset + length > descriptor_size) { + break; + } + const uint8_t type = descriptor[offset + 1]; + if (type == kInterface) { + expect(length == 9, "HID interface descriptor length is wrong"); + const uint8_t number = descriptor[offset + 2]; + expect(number < interfaces.size(), + "HID interface number is outside the configured range"); + if (number < interfaces.size()) { + expect(!interfaces[number], "HID interface number is duplicated"); + interfaces[number] = true; + current_interface = number; + } else { + current_interface = -1; + } + expect(descriptor[offset + 3] == 0 && + descriptor[offset + 4] == 1 && + descriptor[offset + 5] == 0x03 && + descriptor[offset + 6] == 0 && + descriptor[offset + 7] == 0, + "generic HID interface class or endpoint count is wrong"); + } else if (type == kHid) { + expect(current_interface >= 0 && length == 9, + "HID descriptor is not attached to an interface"); + expect(read_u16(descriptor + offset + 7) == + sizeof(GenericHid::kReportDescriptor), + "HID descriptor advertises the wrong report length"); + if (current_interface >= 0) { + ++hid_counts[static_cast(current_interface)]; + } + } else if (type == kEndpoint) { + expect(current_interface >= 0 && length == 7, + "endpoint is not attached to an interface"); + const uint8_t address = descriptor[offset + 2]; + const uint8_t endpoint_number = address & 0x0fu; + expect((address & 0x80u) != 0, + "generic HID exposes an OUT endpoint"); + expect(endpoint_number > 0 && endpoint_number < endpoints.size(), + "generic HID endpoint number is invalid"); + if (endpoint_number < endpoints.size()) { + expect(!endpoints[endpoint_number], + "generic HID endpoint address is duplicated"); + endpoints[endpoint_number] = true; + } + expect(current_interface < 0 || + address == static_cast( + 0x81u + current_interface), + "IN endpoint does not belong to its HID interface"); + expect(descriptor[offset + 3] == 0x03 && + read_u16(descriptor + offset + 4) == + GenericHid::kEndpointSize && + descriptor[offset + 6] == + GenericHid::kEndpointIntervalMs, + "generic HID interrupt endpoint contract is wrong"); + if (current_interface >= 0) { + ++endpoint_counts[static_cast(current_interface)]; + } + } else { + expect(false, "unexpected configuration child descriptor type"); + } + offset += length; + } + + expect(offset == descriptor_size, + "configuration descriptor was not fully decoded"); + for (size_t instance = 0; instance < interfaces.size(); ++instance) { + expect(interfaces[instance] && hid_counts[instance] == 1 && + endpoint_counts[instance] == 1 && endpoints[instance + 1], + "configured HID interface is missing or not isolated"); + } +} + +void inspect_report_encoding() { + ControllerState state{}; + state.left_stick_x = INT16_MIN; + state.left_stick_y = INT16_MAX; + state.right_stick_x = static_cast(0x1234); + state.right_stick_y = static_cast(-0x1234); + state.left_trigger = 0; + state.right_trigger = UINT16_MAX; + GenericHid::InputReport report = GenericHid::build_input_report(state); + expect(read_i16(report.data + 0) == INT16_MIN && + read_i16(report.data + 2) == INT16_MAX && + read_i16(report.data + 4) == static_cast(0x1234) && + read_i16(report.data + 6) == static_cast(-0x1234), + "signed stick endpoints or little-endian encoding are wrong"); + expect(read_u16(report.data + 8) == 0 && + read_u16(report.data + 10) == UINT16_MAX, + "unsigned trigger endpoints or little-endian encoding are wrong"); + expect(report.data[12] == GenericHid::kHatCenter && + (report.data[12] & 0xf0u) == 0, + "neutral Hat Switch does not use null value 8 with zero padding"); + + struct HatCase { + bool up; + bool down; + bool left; + bool right; + uint8_t expected; + }; + constexpr std::array hats{{ + {false, false, false, false, GenericHid::kHatCenter}, + {true, false, false, false, GenericHid::kHatUp}, + {true, false, false, true, GenericHid::kHatUpRight}, + {false, false, false, true, GenericHid::kHatRight}, + {false, true, false, true, GenericHid::kHatDownRight}, + {false, true, false, false, GenericHid::kHatDown}, + {false, true, true, false, GenericHid::kHatDownLeft}, + {false, false, true, false, GenericHid::kHatLeft}, + {true, false, true, false, GenericHid::kHatUpLeft}, + }}; + for (const HatCase& hat : hats) { + ControllerState direction{}; + direction.dpad_up = hat.up; + direction.dpad_down = hat.down; + direction.dpad_left = hat.left; + direction.dpad_right = hat.right; + const GenericHid::InputReport direction_report = + GenericHid::build_input_report(direction); + expect(direction_report.data[12] == hat.expected, + "D-pad direction or diagonal maps to the wrong hat value"); + } + ControllerState contradictory{}; + contradictory.dpad_up = true; + contradictory.dpad_down = true; + contradictory.dpad_left = true; + contradictory.dpad_right = true; + expect(GenericHid::build_input_report(contradictory).data[12] == + GenericHid::kHatCenter, + "contradictory D-pad input does not resolve to center"); + + using ButtonMember = bool ControllerState::*; + constexpr std::array button_members{{ + &ControllerState::button_south, + &ControllerState::button_east, + &ControllerState::button_west, + &ControllerState::button_north, + &ControllerState::button_left_shoulder, + &ControllerState::button_right_shoulder, + &ControllerState::button_select, + &ControllerState::button_start, + &ControllerState::button_left_stick, + &ControllerState::button_right_stick, + &ControllerState::button_system, + &ControllerState::button_capture, + }}; + for (size_t button = 0; button < button_members.size(); ++button) { + ControllerState pressed{}; + pressed.*button_members[button] = true; + const GenericHid::InputReport button_report = + GenericHid::build_input_report(pressed); + expect(read_u16(button_report.data + 13) == (1u << button), + "positional button maps to the wrong sequential usage"); + } + ControllerState all_buttons{}; + for (ButtonMember member : button_members) { + all_buttons.*member = true; + } + expect(read_u16(GenericHid::build_input_report(all_buttons).data + 13) == + GenericHid::kDefinedButtonMask, + "button bits 12..15 are not reserved zero"); +} + +} // namespace + +int main() { + inspect_report_descriptor(); + inspect_device_descriptors_and_strings(); + inspect_configuration_descriptor(); + inspect_report_encoding(); + return failures == 0 ? 0 : 1; +} diff --git a/tests/generic_hid_driver_test.cpp b/tests/generic_hid_driver_test.cpp new file mode 100644 index 0000000..ffe9130 --- /dev/null +++ b/tests/generic_hid_driver_test.cpp @@ -0,0 +1,298 @@ +#include "generic_hid_driver.h" + +#include "generic_hid_descriptors.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr uint8_t kInstanceCount = SWITCH_PICO_HID_INSTANCE_COUNT; +constexpr uint8_t kInvalidInstance = kInstanceCount; +static_assert(kInstanceCount == 4, + "the generic HID driver harness must exercise four interfaces"); + +struct SentReport { + uint8_t instance = 0xff; + uint8_t report_id = 0xff; + uint16_t length = 0; + GenericHid::InputReport report{}; +}; + +std::array hid_ready{}; +std::array send_succeeds{}; +std::array send_attempts{}; +std::array sent_reports{}; +size_t sent_report_count = 0; +int failures = 0; + +void expect(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + ++failures; + } +} + +uint16_t read_u16(const uint8_t* bytes) { + return static_cast(bytes[0]) | + static_cast(static_cast(bytes[1]) << 8u); +} + +void reset_harness() { + hid_ready.fill(true); + send_succeeds.fill(true); + send_attempts = {}; + sent_reports = {}; + sent_report_count = 0; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + generic_hid_init(instance); + } +} + +GenericHid::InputReport get_report(uint8_t instance) { + GenericHid::InputReport report{}; + expect(generic_hid_get_report(instance, 0, HID_REPORT_TYPE_INPUT, + report.data, sizeof(report)) == + sizeof(report), + "valid input GetReport did not return 15 bytes"); + return report; +} + +void test_initial_report_and_descriptor_routing() { + reset_harness(); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + const GenericHid::InputReport report = get_report(instance); + expect(report.data[12] == GenericHid::kHatCenter, + "initialized generic HID report is not centered"); + for (size_t byte = 0; byte < sizeof(report); ++byte) { + if (byte != 12) { + expect(report.data[byte] == 0, + "initialized generic HID report is not neutral"); + } + } + const uint8_t* descriptor = + generic_hid_report_descriptor(instance); + expect(descriptor != nullptr && + std::memcmp(descriptor, GenericHid::kReportDescriptor, + sizeof(GenericHid::kReportDescriptor)) == 0, + "valid HID instance did not receive the shared descriptor"); + } + expect(generic_hid_report_descriptor(kInvalidInstance) == nullptr, + "invalid HID instance received a report descriptor"); +} + +void test_latest_get_report_and_instance_isolation() { + reset_harness(); + std::array states{}; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + ControllerState& state = states[instance]; + state.left_stick_x = static_cast(-30000 + instance * 1000); + state.left_stick_y = static_cast(1000 + instance * 2000); + state.right_stick_x = static_cast(3000 + instance * 3000); + state.right_stick_y = static_cast(-4000 - instance * 4000); + state.left_trigger = static_cast(0x1111u * (instance + 1u)); + state.right_trigger = static_cast(0x8888u + instance * 0x1111u); + if (instance == 0) { + state.button_south = true; + state.dpad_up = true; + } else if (instance == 1) { + state.button_east = true; + state.dpad_right = true; + } else if (instance == 2) { + state.button_system = true; + state.dpad_down = true; + } else { + state.button_capture = true; + state.dpad_left = true; + } + generic_hid_set_input(instance, state); + } + + std::array reports{}; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + reports[instance] = get_report(instance); + const GenericHid::InputReport expected = + GenericHid::build_input_report(states[instance]); + expect(std::memcmp(reports[instance].data, expected.data, + sizeof(expected)) == 0, + "GetReport did not expose the latest addressed input"); + } + for (uint8_t left = 0; left < kInstanceCount; ++left) { + for (uint8_t right = static_cast(left + 1u); + right < kInstanceCount; ++right) { + expect(std::memcmp(reports[left].data, reports[right].data, + sizeof(GenericHid::InputReport)) != 0, + "latest reports crossed HID instances"); + } + } + + ControllerState changed = states[2]; + changed.button_system = false; + changed.button_north = true; + changed.left_trigger = UINT16_MAX; + generic_hid_set_input(2, changed); + const GenericHid::InputReport latest = get_report(2); + const GenericHid::InputReport unchanged = get_report(1); + expect(read_u16(latest.data + 13) == GenericHid::kButtonNorth && + read_u16(latest.data + 8) == UINT16_MAX, + "changed input was not materialized before the next task call"); + expect(std::memcmp(unchanged.data, reports[1].data, sizeof(unchanged)) == 0, + "changing one input mutated another HID context"); +} + +void test_ready_and_periodic_send_routing() { + reset_harness(); + ControllerState state{}; + state.left_stick_x = INT16_MIN; + state.right_stick_y = INT16_MAX; + state.left_trigger = 0x1234; + state.right_trigger = 0xabcd; + state.button_left_stick = true; + state.dpad_down = true; + state.dpad_right = true; + generic_hid_set_input(3, state); + + hid_ready[3] = false; + expect(!generic_hid_is_ready(3) && !generic_hid_task(3) && + send_attempts[3] == 0 && sent_report_count == 0, + "not-ready HID instance attempted an interrupt send"); + hid_ready[3] = true; + expect(generic_hid_is_ready(3) && generic_hid_task(3) && + send_attempts[3] == 1 && sent_report_count == 1, + "ready HID instance did not send its periodic input report"); + expect(sent_reports[0].instance == 3 && + sent_reports[0].report_id == 0 && + sent_reports[0].length == GenericHid::kReportSize, + "generic input used the wrong TinyUSB interface, ID, or length"); + const GenericHid::InputReport expected = + GenericHid::build_input_report(state); + expect(std::memcmp(sent_reports[0].report.data, expected.data, + sizeof(expected)) == 0, + "interrupt send did not use the latest generic input report"); + + expect(generic_hid_task(3) && send_attempts[3] == 2 && + sent_report_count == 2, + "ready polling did not permit the next report interval send"); + + send_succeeds[3] = false; + expect(!generic_hid_task(3) && send_attempts[3] == 3 && + sent_report_count == 2, + "failed TinyUSB send was reported as successful"); + expect(!generic_hid_is_ready(kInvalidInstance) && + !generic_hid_task(kInvalidInstance), + "invalid HID instance was ready or attempted a send"); + for (uint8_t instance = 0; instance < 3; ++instance) { + expect(send_attempts[instance] == 0, + "task send leaked to a different HID interface"); + } +} + +void test_reset_and_invalid_inputs() { + reset_harness(); + ControllerState zero{}; + zero.button_south = true; + zero.left_stick_x = 1234; + ControllerState one{}; + one.button_east = true; + one.right_trigger = 4321; + generic_hid_set_input(0, zero); + generic_hid_set_input(1, one); + + generic_hid_init(0); + const GenericHid::InputReport reset = get_report(0); + const GenericHid::InputReport preserved = get_report(1); + expect(reset.data[12] == GenericHid::kHatCenter && + read_u16(reset.data + 13) == 0 && + read_u16(reset.data) == 0, + "init did not reset the addressed HID context"); + expect(read_u16(preserved.data + 13) == GenericHid::kButtonEast && + read_u16(preserved.data + 10) == 4321, + "reset crossed into another HID context"); + + ControllerState invalid{}; + invalid.button_capture = true; + generic_hid_set_input(kInvalidInstance, invalid); + generic_hid_init(kInvalidInstance); + expect(std::memcmp(get_report(1).data, preserved.data, + sizeof(preserved)) == 0, + "invalid instance input/reset mutated a valid context"); +} + +void test_get_report_rejections_and_truncation() { + reset_harness(); + ControllerState state{}; + state.left_stick_x = static_cast(0x1234); + state.left_stick_y = static_cast(0x5678); + generic_hid_set_input(0, state); + + std::array buffer{}; + buffer.fill(0xa5); + expect(generic_hid_get_report(kInvalidInstance, 0, + HID_REPORT_TYPE_INPUT, buffer.data(), + buffer.size()) == 0, + "GetReport accepted an invalid HID instance"); + expect(generic_hid_get_report(0, 1, HID_REPORT_TYPE_INPUT, buffer.data(), + buffer.size()) == 0, + "GetReport accepted a nonzero Report ID"); + expect(generic_hid_get_report(0, 0, HID_REPORT_TYPE_OUTPUT, buffer.data(), + buffer.size()) == 0 && + generic_hid_get_report(0, 0, HID_REPORT_TYPE_FEATURE, + buffer.data(), buffer.size()) == 0, + "input-only driver accepted output or feature GetReport"); + expect(generic_hid_get_report(0, 0, HID_REPORT_TYPE_INPUT, nullptr, + buffer.size()) == 0 && + generic_hid_get_report(0, 0, HID_REPORT_TYPE_INPUT, + buffer.data(), 0) == 0, + "GetReport accepted an invalid destination"); + for (uint8_t byte : buffer) { + expect(byte == 0xa5, "rejected GetReport modified its destination"); + } + + std::array truncated{}; + expect(generic_hid_get_report(0, 0, HID_REPORT_TYPE_INPUT, + truncated.data(), truncated.size()) == + truncated.size() && + truncated[0] == 0x34 && truncated[1] == 0x12 && + truncated[2] == 0x78, + "GetReport did not safely truncate the latest input report"); +} + +} // namespace + +extern "C" bool tud_hid_n_ready(uint8_t instance) { + return instance < kInstanceCount && hid_ready[instance]; +} + +extern "C" bool tud_hid_n_report(uint8_t instance, uint8_t report_id, + const void* report, uint16_t length) { + if (instance >= kInstanceCount || report == nullptr || + length != sizeof(GenericHid::InputReport)) { + return false; + } + ++send_attempts[instance]; + if (!send_succeeds[instance] || sent_report_count >= sent_reports.size()) { + return false; + } + SentReport& sent = sent_reports[sent_report_count++]; + sent.instance = instance; + sent.report_id = report_id; + sent.length = length; + std::memcpy(&sent.report, report, length); + return true; +} + +int main() { + test_initial_report_and_descriptor_routing(); + test_latest_get_report_and_instance_isolation(); + test_ready_and_periodic_send_routing(); + test_reset_and_invalid_inputs(); + test_get_report_rejections_and_truncation(); + if (failures != 0) { + std::cerr << failures << " generic HID driver test(s) failed\n"; + return 1; + } + return 0; +} diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index f5129cb..c44176a 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -44,6 +44,11 @@ class FakeDevice: ) self.configuration_generation = 3 self.active_mode = config_manager.ACTIVE_MODE_SWITCH_PROBE + self.capabilities = ( + config_manager.CAPABILITY_INPUT + | config_manager.CAPABILITY_RUMBLE + | config_manager.CAPABILITY_MOTION + ) self.transaction_id = 0 self.transaction_payload = bytearray() self.transaction_expected_size = 0 @@ -215,7 +220,7 @@ class FakeDevice: 0, 2, self.active_mode, - 0, + self.capabilities, 0, 2, ] @@ -366,6 +371,8 @@ class FakeDevice: config_manager.REQUESTED_MODE_AUTO, config_manager.REQUESTED_MODE_SWITCH, config_manager.REQUESTED_MODE_XINPUT, + config_manager.REQUESTED_MODE_DINPUT, + config_manager.REQUESTED_MODE_MAC, ) self.transaction_payload = bytearray() self.transaction_expected_size = 0 @@ -650,17 +657,20 @@ def test_mode_envelopes_and_host_side_validation( for transaction_id in (0, 0x80000000, True): with pytest.raises(config_manager.ConfigManagerError): config_manager.request_reboot(device, transaction_id) - for mode in ( - config_manager.REQUESTED_MODE_DINPUT, - config_manager.REQUESTED_MODE_MAC, - 0xFF, - True, - ): + for mode in (0xFF, True): with pytest.raises( config_manager.ConfigManagerError, match="not available" ): config_manager.set_mode(device, mode, 1.0) + for mode in ( + config_manager.REQUESTED_MODE_DINPUT, + config_manager.REQUESTED_MODE_MAC, + ): + status = config_manager.set_mode(device, mode, 1.0) + assert status.status == config_manager.STATUS_OK + assert device.pending_requested_mode is None + @pytest.mark.parametrize( ("failure_status", "message"), @@ -728,6 +738,14 @@ def test_mode_transaction_must_correlate_before_reboot( config_manager.REQUESTED_MODE_XINPUT, config_manager.ACTIVE_MODE_XINPUT, ), + ( + config_manager.REQUESTED_MODE_DINPUT, + config_manager.ACTIVE_MODE_DINPUT, + ), + ( + config_manager.REQUESTED_MODE_MAC, + config_manager.ACTIVE_MODE_MAC, + ), ), ) def test_mode_noop_accepts_only_mode_appropriate_active_state( @@ -736,6 +754,16 @@ def test_mode_noop_accepts_only_mode_appropriate_active_state( device = FakeDevice() device.configuration = struct.pack(" None: config_manager.read_info(device).active_mode == config_manager.ACTIVE_MODE_SWITCH_PROBE ) + assert config_manager.read_info(device).capability_names() == ( + "input", + "rumble", + "motion", + ) device.configuration = struct.pack(" None: config_manager.ConfigManagerError, match="unknown active USB mode" ): config_manager.read_info(device) + device.active_mode = config_manager.ACTIVE_MODE_DINPUT + device.capabilities = 0x80 + with pytest.raises( + config_manager.ConfigManagerError, match="unknown device capability" + ): + config_manager.read_info(device) + device.capabilities = config_manager.CAPABILITY_RUMBLE + with pytest.raises( + config_manager.ConfigManagerError, match="omit required input" + ): + config_manager.read_info(device) def test_identity_and_profile_binary_json_round_trip() -> None: @@ -1473,6 +1550,19 @@ def test_status_and_pairing_commands( assert "Pairing window: 60 seconds" in output assert "Requested USB mode: auto" in output assert "Active USB mode: Switch probe" in output + assert "Mode capabilities: input, rumble, motion" in output + + device.configuration = struct.pack( + " None: 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_mode_parser_accepts_all_implemented_modes() -> None: + for mode in config_manager.REQUESTED_MODE_NAMES: + args = config_manager.build_parser().parse_args(["mode", mode]) + assert args.mode == mode -def test_candidate_discovery_checks_switch_and_xinput_identities( +def test_candidate_discovery_checks_all_usb_identities( monkeypatch: pytest.MonkeyPatch, ) -> None: lookups: list[tuple[int, int]] = [] + assert config_manager.USB_IDENTITIES == ( + (0x057E, 0x2009), + (0xCAFE, 0x4010), + (0xCAFE, 0x4020), + (0xCAFE, 0x4021), + ) + def find(**arguments: object) -> tuple[object, ...]: lookups.append( ( diff --git a/tests/test_generic_hid_native.py b/tests/test_generic_hid_native.py new file mode 100644 index 0000000..7c3e500 --- /dev/null +++ b/tests/test_generic_hid_native.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def host_compiler() -> str: + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + return compiler + + +def compile_cpp( + root: Path, + compiler: str, + output: Path, + sources: list[Path], + definitions: list[str], + include_stubs: bool = False, +) -> subprocess.CompletedProcess[str]: + command = [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + *[f"-D{definition}" for definition in definitions], + ] + if include_stubs: + command.append(f"-I{root / 'tests' / 'native_stubs'}") + command.extend( + [ + f"-I{root}", + *[str(source) for source in sources], + "-o", + str(output), + ] + ) + return subprocess.run( + command, + check=False, + cwd=root, + text=True, + capture_output=True, + ) + + +def test_generic_hid_descriptor_and_report_contracts(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = host_compiler() + source = root / "tests" / "generic_hid_descriptors_test.cpp" + for instance_count in range(1, 5): + executable = tmp_path / f"generic_hid_descriptors_{instance_count}_test" + result = compile_cpp( + root, + compiler, + executable, + [source], + [ + f"EXPECTED_HID_INSTANCE_COUNT={instance_count}", + f"SWITCH_PICO_HID_INSTANCE_COUNT={instance_count}", + ], + ) + assert result.returncode == 0, result.stderr + subprocess.run([str(executable)], check=True, cwd=root) + + +def test_generic_hid_rejects_unsupported_interface_counts( + tmp_path: Path, +) -> None: + root = Path(__file__).resolve().parents[1] + compiler = host_compiler() + source = root / "tests" / "generic_hid_descriptors_test.cpp" + for instance_count in (0, 5): + executable = tmp_path / f"generic_hid_descriptors_{instance_count}_test" + result = compile_cpp( + root, + compiler, + executable, + [source], + [ + f"EXPECTED_HID_INSTANCE_COUNT={instance_count}", + f"SWITCH_PICO_HID_INSTANCE_COUNT={instance_count}", + ], + ) + assert result.returncode != 0, ( + f"unsupported interface count {instance_count} compiled successfully" + ) + + +def test_generic_hid_driver_contexts(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + executable = tmp_path / "generic_hid_driver_test" + result = compile_cpp( + root, + host_compiler(), + executable, + [ + root / "generic_hid_driver.cpp", + root / "tests" / "generic_hid_driver_test.cpp", + ], + ["SWITCH_PICO_HID_INSTANCE_COUNT=4"], + include_stubs=True, + ) + assert result.returncode == 0, result.stderr + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_usb_output_driver_native.py b/tests/test_usb_output_driver_native.py index 0c91f77..b6f4ff8 100644 --- a/tests/test_usb_output_driver_native.py +++ b/tests/test_usb_output_driver_native.py @@ -40,6 +40,7 @@ def test_usb_output_driver_contracts(tmp_path: Path) -> None: str(root / "tests" / "usb_output_driver_test.cpp"), str(root / "switch_pro_driver.cpp"), str(root / "usb_output_driver.cpp"), + str(root / "generic_hid_driver.cpp"), str(root / "xinput_driver.cpp"), str(root / "switch_haptics.cpp"), "-o", diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index a02f3b2..2b3cfa7 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -6,6 +6,7 @@ #include #include +#include "usb_output_driver.h" namespace { @@ -15,12 +16,15 @@ ProfileServiceListSnapshot current_profile_list{}; ProfileServiceSelectedSnapshot current_profile_selected{}; ProfileServiceTransactionSnapshot current_profile_transaction{}; AdapterUsbMode current_active_mode = AdapterUsbMode::kSwitchProbe; +uint8_t current_capabilities = + USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION; ConfigurationTransactionStatus mode_set_result = ConfigurationTransactionStatus::kPending; uint32_t mode_set_transaction_id = 0; AdapterRequestedMode mode_set_requested_mode = AdapterRequestedMode::kAuto; AdapterModeAvailability mode_set_availability{}; -AdapterModeAvailability runtime_mode_availability{}; +AdapterModeAvailability runtime_mode_availability{true, true, true, true}; uint32_t mode_availability_query_count = 0; uint32_t mode_set_call_count = 0; uint32_t correlated_reboot_transaction_id = 0; @@ -256,8 +260,24 @@ void test_mode_vendor_requests() { 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"); + static_cast(AdapterUsbMode::kSwitchProbe) && + control_payload[kResponseHeaderSize + 5] == + current_capabilities, + "info response did not report active mode capabilities"); + + current_active_mode = AdapterUsbMode::kDInput; + current_capabilities = USB_OUTPUT_CAPABILITY_INPUT; + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload[kResponseHeaderSize + 4] == + static_cast(AdapterUsbMode::kDInput) && + control_payload[kResponseHeaderSize + 5] == + USB_OUTPUT_CAPABILITY_INPUT, + "generic info response promised unsupported output capabilities"); + current_active_mode = AdapterUsbMode::kSwitchProbe; + current_capabilities = + USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION; request = setup_request( Operation::kConfigurationRead, TUSB_DIR_IN, @@ -296,8 +316,8 @@ void test_mode_vendor_requests() { AdapterRequestedMode::kXInput && mode_set_availability.switch_mode && mode_set_availability.xinput_mode && - !mode_set_availability.dinput_mode && - !mode_set_availability.mac_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); @@ -330,17 +350,22 @@ void test_mode_vendor_requests() { "malformed mode request reached runtime mode selection"); mode_set_result = ConfigurationTransactionStatus::kPending; - for (const AdapterRequestedMode unavailable : { + for (const AdapterRequestedMode generic_mode : { AdapterRequestedMode::kDInput, AdapterRequestedMode::kMac, }) { - mode_set[4] = static_cast(unavailable); - perform_out(Operation::kModeSet, mode_set, false); + write_u32(&mode_set, 0, + 0x12345680u + + static_cast(generic_mode)); + mode_set[4] = static_cast(generic_mode); + perform_out(Operation::kModeSet, mode_set); + require(mode_set_requested_mode == generic_mode, + "generic mode request was not dispatched"); } require(mode_set_call_count == calls_before_invalid + 2 && mode_availability_query_count == availability_queries_before_invalid + 2, - "unsupported mode did not use runtime availability"); + "generic modes did not use runtime availability"); request = setup_request( Operation::kModeSet, TUSB_DIR_OUT, kRequestHeaderSize + 4); @@ -615,6 +640,9 @@ ConfigurationTransactionStatus configuration_service_set_mode( } AdapterUsbMode usb_output_driver_mode() { return current_active_mode; } +uint8_t usb_output_driver_capabilities() { + return current_capabilities; +} bool adapter_reboot_for_mode_transaction(uint32_t transaction_id) { reboot_transaction_id = transaction_id; diff --git a/tests/usb_output_driver_test.cpp b/tests/usb_output_driver_test.cpp index 7332e34..50e291c 100644 --- a/tests/usb_output_driver_test.cpp +++ b/tests/usb_output_driver_test.cpp @@ -5,6 +5,8 @@ #include #include "adapter_host_probe_state.h" +#include "generic_hid_descriptors.h" +#include "generic_hid_driver.h" #include "xinput_descriptors.h" #include "xinput_protocol.h" #include "device/usbd_pvt.h" @@ -297,6 +299,11 @@ void test_switch_boundary_dispatch() { std::strcmp(usb_output_driver_mode_name(), "Switch probe") == 0, "Switch boundary mode was not frozen"); + expect(usb_output_driver_capabilities() == + (USB_OUTPUT_CAPABILITY_INPUT | + USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION), + "Switch probe capabilities were not rumble plus motion"); expect(std::memcmp(tud_descriptor_device_cb(), XInput::kSwitchProbeDeviceDescriptor, sizeof(XInput::kSwitchProbeDeviceDescriptor)) == 0, @@ -386,6 +393,11 @@ void test_manual_switch_selection() { "manual Switch did not use the production Switch descriptor"); expect(tud_descriptor_string_cb(0xee, 0x0409) == nullptr, "manual Switch exposed the automatic Windows probe string"); + expect(usb_output_driver_capabilities() == + (USB_OUTPUT_CAPABILITY_INPUT | + USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION), + "manual Switch capabilities lost rumble or motion"); } void open_xinput_interfaces(usbd_class_driver_t const* driver) { @@ -415,6 +427,12 @@ void test_xinput_boundary_dispatch() { std::strcmp(usb_output_driver_name(), "XINPUT") == 0 && std::strcmp(usb_output_driver_mode_name(), "XInput") == 0, "XInput boundary mode was not frozen"); + expect(usb_output_driver_capabilities() == + (USB_OUTPUT_CAPABILITY_INPUT | + USB_OUTPUT_CAPABILITY_RUMBLE) && + (usb_output_driver_capabilities() & + USB_OUTPUT_CAPABILITY_MOTION) == 0, + "XInput capabilities did not report rumble without motion"); expect(std::memcmp(tud_descriptor_device_cb(), XInput::kDeviceDescriptor, sizeof(XInput::kDeviceDescriptor)) == 0, @@ -540,6 +558,135 @@ void test_xinput_boundary_dispatch() { "XInput development serial string changed"); } +void test_generic_boundary_dispatch( + AdapterUsbMode mode, const uint8_t* expected_device_descriptor, + const char* expected_driver_name, const char* expected_mode_name, + const char* expected_product, const char* expected_serial) { + reset_usb_harness(); + usb_output_driver_init(mode); + + expect(usb_output_driver_mode() == mode && + std::strcmp(usb_output_driver_name(), + expected_driver_name) == 0 && + std::strcmp(usb_output_driver_mode_name(), + expected_mode_name) == 0, + "generic boundary mode was not frozen"); + expect(usb_output_driver_capabilities() == + USB_OUTPUT_CAPABILITY_INPUT && + (usb_output_driver_capabilities() & + USB_OUTPUT_CAPABILITY_RUMBLE) == 0 && + (usb_output_driver_capabilities() & + USB_OUTPUT_CAPABILITY_MOTION) == 0, + "generic mode promised rumble or motion capability"); + const uint8_t* device_descriptor = tud_descriptor_device_cb(); + expect(device_descriptor != nullptr && + std::memcmp(device_descriptor, expected_device_descriptor, + sizeof(GenericHid::kDInputDeviceDescriptor)) == 0, + "generic device descriptor was not selected"); + const uint8_t* configuration_descriptor = + tud_descriptor_configuration_cb(0); + expect(configuration_descriptor != nullptr && + std::memcmp(configuration_descriptor, + GenericHid::kConfigurationDescriptor, + GenericHid::kConfigurationDescriptorSize) == 0, + "generic configuration descriptor was not selected"); + const uint8_t* report_descriptor = + tud_hid_descriptor_report_cb(0); + expect(report_descriptor != nullptr && + std::memcmp(report_descriptor, + GenericHid::kReportDescriptor, + sizeof(GenericHid::kReportDescriptor)) == 0 && + tud_hid_descriptor_report_cb(kInvalidInstance) == nullptr, + "generic report descriptor routing was incorrect"); + + uint8_t driver_count = 0xff; + expect(usbd_app_driver_get_cb(&driver_count) == nullptr && + driver_count == 0, + "generic mode registered the XInput custom class"); + expect(tud_descriptor_string_cb(0xee, 0x0409) == nullptr, + "generic mode exposed the XInput Microsoft OS string"); + expect_usb_string(1, GenericHid::kManufacturerString, + "generic manufacturer string changed"); + expect_usb_string(2, expected_product, + "generic product string changed"); + expect_usb_string(3, expected_serial, + "generic serial string changed"); + + std::array expected_reports{}; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + ControllerState state{}; + state.left_stick_x = + static_cast(INT16_MIN + instance); + state.right_stick_y = + static_cast(INT16_MAX - instance); + state.left_trigger = + static_cast(0x1111u * (instance + 1u)); + state.button_south = (instance & 1u) == 0; + state.button_north = (instance & 1u) != 0; + usb_output_driver_set_input(instance, state, 1, 2); + expected_reports[instance] = + GenericHid::build_input_report(state); + } + + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + std::array report{}; + expect(tud_hid_get_report_cb( + instance, 0, HID_REPORT_TYPE_INPUT, report.data(), + report.size()) == GenericHid::kReportSize && + std::memcmp(report.data(), + expected_reports[instance].data, + report.size()) == 0, + "generic GET_REPORT crossed instance state"); + expect(usb_output_driver_is_ready(instance) && + usb_output_driver_task(instance), + "generic readiness/task helper was not dispatched"); + } + std::array invalid_report{}; + expect(tud_hid_get_report_cb( + kInvalidInstance, 0, HID_REPORT_TYPE_INPUT, + invalid_report.data(), invalid_report.size()) == 0 && + !usb_output_driver_is_ready(kInvalidInstance) && + !usb_output_driver_task(kInvalidInstance), + "generic callbacks accepted an invalid instance"); + expect(tud_hid_get_report_cb( + 0, 0, HID_REPORT_TYPE_OUTPUT, invalid_report.data(), + invalid_report.size()) == 0 && + tud_hid_get_report_cb( + 0, 1, HID_REPORT_TYPE_INPUT, invalid_report.data(), + invalid_report.size()) == 0, + "generic boundary accepted output or report-ID GET_REPORT"); + + switch_pro_set_rumble_callback(0, + inactive_switch_rumble_callback); + usb_output_driver_set_rumble_callback(0, xinput_rumble_callback); + std::array output{}; + output[0] = REPORT_OUTPUT_10; + tud_hid_report_received_cb(0, 0, output.data(), output.size()); + tud_hid_set_report_cb(0, REPORT_OUTPUT_10, HID_REPORT_TYPE_OUTPUT, + output.data() + 1, output.size() - 1); + expect(switch_inactive_rumble_count == 0 && + xinput_rumble_events[0].count == 0, + "generic input-only mode handled an output report"); + + tud_mount_cb(); + tud_umount_cb(); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(usb_output_driver_is_ready(instance), + "generic lifecycle callback disturbed active state"); + } +} + +void test_generic_modes_boundary_dispatch() { + test_generic_boundary_dispatch( + AdapterUsbMode::kDInput, GenericHid::kDInputDeviceDescriptor, + "DINPUT", "DInput", GenericHid::kDInputProductString, + GenericHid::kDInputSerialString); + test_generic_boundary_dispatch( + AdapterUsbMode::kMac, GenericHid::kMacDeviceDescriptor, + "MAC", "Mac", GenericHid::kMacProductString, + GenericHid::kMacSerialString); +} + void test_vendor_control_boundary() { constexpr uint8_t kSetupStage = 0; constexpr uint8_t kRhport = 2; @@ -687,6 +834,7 @@ int main() { test_switch_boundary_dispatch(); test_manual_switch_selection(); test_xinput_boundary_dispatch(); + test_generic_modes_boundary_dispatch(); test_vendor_control_boundary(); return failures == 0 ? 0 : 1; } diff --git a/tusb_config.h b/tusb_config.h index a6f81b6..68e9be5 100644 --- a/tusb_config.h +++ b/tusb_config.h @@ -1,5 +1,5 @@ -// TinyUSB configuration for one to four Switch Pro style HID interfaces. -// Each interface uses independent 64-byte interrupt IN and OUT endpoints. +// Switch Pro interfaces use independent 64-byte interrupt IN and OUT endpoints; +// generic HID interfaces use input-only 15-byte reports/endpoints. #ifndef _TUSB_CONFIG_H_ #define _TUSB_CONFIG_H_ @@ -36,7 +36,7 @@ extern "C" { #define CFG_TUD_MSC 0 #define CFG_TUD_MIDI 0 #define CFG_TUD_VENDOR 0 -// Always enable TinyUSB debug at level 2; LOG_PRINTF controls user-facing logs. +// TinyUSB debug is disabled by default (CFG_TUSB_DEBUG=0); LOG_PRINTF controls user-facing logs. #ifdef CFG_TUSB_DEBUG #undef CFG_TUSB_DEBUG #endif diff --git a/usb_configuration_management.cpp b/usb_configuration_management.cpp index ff24aea..68be855 100644 --- a/usb_configuration_management.cpp +++ b/usb_configuration_management.cpp @@ -153,7 +153,7 @@ size_t encode_info(uint8_t* output, size_t output_size) { uint8_t payload[8] = { 0, 2, 0, 2, static_cast(usb_output_driver_mode()), - 0, + usb_output_driver_capabilities(), static_cast(CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE), static_cast( CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE >> 8), diff --git a/usb_output_driver.cpp b/usb_output_driver.cpp index 83c1a21..3393fb6 100644 --- a/usb_output_driver.cpp +++ b/usb_output_driver.cpp @@ -14,6 +14,8 @@ #ifdef SWITCH_PICO_USB_OUTPUT_MODES #include "adapter_host_probe.h" +#include "generic_hid_descriptors.h" +#include "generic_hid_driver.h" #include "xinput_descriptors.h" #include "xinput_driver.h" #endif @@ -37,6 +39,19 @@ bool xinput_selected() { #endif } +bool generic_selected() { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + return g_mode == AdapterUsbMode::kDInput || + g_mode == AdapterUsbMode::kMac; +#else + return false; +#endif +} + +bool switch_selected() { + return !xinput_selected() && !generic_selected(); +} + } // namespace void usb_output_driver_init(AdapterUsbMode mode) { @@ -52,6 +67,8 @@ void usb_output_driver_init(AdapterUsbMode mode) { #ifdef SWITCH_PICO_USB_OUTPUT_MODES if (xinput_selected()) { xinput_init(instance); + } else if (generic_selected()) { + generic_hid_init(instance); } else #endif { @@ -68,6 +85,10 @@ const char* usb_output_driver_mode_name() { return "Switch probe"; case AdapterUsbMode::kXInput: return "XInput"; + case AdapterUsbMode::kDInput: + return "DInput"; + case AdapterUsbMode::kMac: + return "Mac"; } return "Switch"; } @@ -77,7 +98,35 @@ AdapterUsbMode usb_output_driver_mode() { } const char* usb_output_driver_name() { - return xinput_selected() ? "XINPUT" : "SWITCH"; + switch (g_mode) { + case AdapterUsbMode::kXInput: + return "XINPUT"; + case AdapterUsbMode::kDInput: + return "DINPUT"; + case AdapterUsbMode::kMac: + return "MAC"; + case AdapterUsbMode::kSwitch: + case AdapterUsbMode::kSwitchProbe: + return "SWITCH"; + } + return "SWITCH"; +} + +uint8_t usb_output_driver_capabilities() { + switch (g_mode) { + case AdapterUsbMode::kSwitch: + case AdapterUsbMode::kSwitchProbe: + return USB_OUTPUT_CAPABILITY_INPUT | + USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION; + case AdapterUsbMode::kXInput: + return USB_OUTPUT_CAPABILITY_INPUT | + USB_OUTPUT_CAPABILITY_RUMBLE; + case AdapterUsbMode::kDInput: + case AdapterUsbMode::kMac: + return USB_OUTPUT_CAPABILITY_INPUT; + } + return USB_OUTPUT_CAPABILITY_INPUT; } void usb_output_driver_set_input(uint8_t instance, @@ -89,6 +138,10 @@ void usb_output_driver_set_input(uint8_t instance, xinput_set_input(instance, state); return; } + if (generic_selected()) { + generic_hid_set_input(instance, state); + return; + } #endif switch_pro_set_input(instance, state, left_trigger_threshold, right_trigger_threshold); @@ -99,6 +152,9 @@ bool usb_output_driver_task(uint8_t instance) { if (xinput_selected()) { return xinput_task(instance); } + if (generic_selected()) { + return generic_hid_task(instance); + } #endif return switch_pro_task(instance); } @@ -108,6 +164,9 @@ bool usb_output_driver_is_ready(uint8_t instance) { if (xinput_selected()) { return xinput_is_ready(instance); } + if (generic_selected()) { + return generic_hid_is_ready(instance); + } #endif return switch_pro_is_ready(instance); } @@ -119,6 +178,9 @@ void usb_output_driver_set_rumble_callback( xinput_set_rumble_callback(instance, callback); return; } + if (generic_selected()) { + return; + } #endif switch_pro_set_rumble_callback(instance, callback); } @@ -126,9 +188,15 @@ void usb_output_driver_set_rumble_callback( extern "C" uint16_t tud_hid_get_report_cb( uint8_t instance, uint8_t report_id, hid_report_type_t report_type, uint8_t* buffer, uint16_t requested_length) { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES if (xinput_selected()) { return 0; } + if (generic_selected()) { + return generic_hid_get_report(instance, report_id, report_type, + buffer, requested_length); + } +#endif return switch_pro_hid_get_report(instance, report_id, report_type, buffer, requested_length); } @@ -136,7 +204,7 @@ extern "C" uint16_t tud_hid_get_report_cb( extern "C" void tud_hid_set_report_cb( uint8_t instance, uint8_t report_id, hid_report_type_t report_type, const uint8_t* buffer, uint16_t buffer_size) { - if (!xinput_selected()) { + if (switch_selected()) { switch_pro_hid_set_report(instance, report_id, report_type, buffer, buffer_size); } @@ -145,16 +213,21 @@ extern "C" void tud_hid_set_report_cb( extern "C" void tud_hid_report_received_cb( uint8_t instance, uint8_t report_id, const uint8_t* buffer, uint16_t buffer_size) { - if (!xinput_selected()) { + if (switch_selected()) { switch_pro_hid_report_received(instance, report_id, buffer, buffer_size); } } extern "C" uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance) { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES if (xinput_selected()) { return nullptr; } + if (generic_selected()) { + return generic_hid_report_descriptor(instance); + } +#endif return switch_pro_hid_report_descriptor(instance); } @@ -163,6 +236,11 @@ extern "C" uint8_t const* tud_descriptor_device_cb() { if (xinput_selected()) { return XInput::kDeviceDescriptor; } + if (generic_selected()) { + return g_mode == AdapterUsbMode::kDInput + ? GenericHid::kDInputDeviceDescriptor + : GenericHid::kMacDeviceDescriptor; + } if (g_mode == AdapterUsbMode::kSwitchProbe) { return XInput::kSwitchProbeDeviceDescriptor; } @@ -176,6 +254,9 @@ extern "C" uint8_t const* tud_descriptor_configuration_cb(uint8_t index) { if (xinput_selected()) { return XInput::kConfigurationDescriptor; } + if (generic_selected()) { + return GenericHid::kConfigurationDescriptor; + } #endif return switch_pro_configuration_descriptor; } @@ -186,7 +267,9 @@ extern "C" uint16_t const* tud_descriptor_string_cb(uint8_t index, #ifdef SWITCH_PICO_USB_OUTPUT_MODES adapter_host_probe_note_string_descriptor(index); - if (index == 0xee && g_mode != AdapterUsbMode::kSwitch) { + if (index == 0xee && + (xinput_selected() || + g_mode == AdapterUsbMode::kSwitchProbe)) { static constexpr char kSignature[] = "MSFT100"; for (uint8_t i = 0; i < sizeof(kSignature) - 1; ++i) { g_string_descriptor[1 + i] = kSignature[i]; @@ -216,6 +299,23 @@ extern "C" uint16_t const* tud_descriptor_string_cb(uint8_t index, return nullptr; } string = kXInputStrings[index]; + } else if (generic_selected()) { + if (index == 1) { + string = reinterpret_cast( + GenericHid::kManufacturerString); + } else if (index == 2) { + string = reinterpret_cast( + g_mode == AdapterUsbMode::kDInput + ? GenericHid::kDInputProductString + : GenericHid::kMacProductString); + } else if (index == 3) { + string = reinterpret_cast( + g_mode == AdapterUsbMode::kDInput + ? GenericHid::kDInputSerialString + : GenericHid::kMacSerialString); + } else { + return nullptr; + } } else #endif { @@ -271,14 +371,14 @@ extern "C" bool tud_control_request_cb( extern "C" void tud_mount_cb() { LOG_PRINTF("[USB] mount_cb\n"); - if (!xinput_selected()) { + if (switch_selected()) { switch_pro_mount(); } } extern "C" void tud_umount_cb() { LOG_PRINTF("[USB] umount_cb\n"); - if (!xinput_selected()) { + if (switch_selected()) { switch_pro_unmount(); } } diff --git a/usb_output_driver.h b/usb_output_driver.h index 6b55d26..ec2bad4 100644 --- a/usb_output_driver.h +++ b/usb_output_driver.h @@ -6,6 +6,15 @@ #include "controller_state.h" #include "switch_haptics.h" +// Mode capability bits published in USB management info. Input is present in +// every current mode; rumble/motion describe host-visible USB capabilities. +constexpr uint8_t USB_OUTPUT_CAPABILITY_INPUT = 1u << 0; +constexpr uint8_t USB_OUTPUT_CAPABILITY_RUMBLE = 1u << 1; +constexpr uint8_t USB_OUTPUT_CAPABILITY_MOTION = 1u << 2; +constexpr uint8_t USB_OUTPUT_CAPABILITY_MASK = + USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION; + // Select and initialize the static USB output implementation. Call once before // tusb_init(); the selected descriptors and class driver remain fixed for the // lifetime of the USB device stack. @@ -14,6 +23,7 @@ void usb_output_driver_init(AdapterUsbMode mode); const char* usb_output_driver_mode_name(); AdapterUsbMode usb_output_driver_mode(); const char* usb_output_driver_name(); +uint8_t usb_output_driver_capabilities(); void usb_output_driver_set_input(uint8_t instance, const ControllerState& state,