diff --git a/README.md b/README.md index 1d9609a..6e248f2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ A cross-platform tool to control USB gaming headsets on **Linux**, **macOS**, an | Logitech Zone Wired/Zone 750 | All | x | | | | | | x | x | | | | | | | | | | | Logitech ASTRO A50 Gen 5 | All | x | x | | x | | x | | | | | x | | | | | | x | | Corsair Headset Device | All | x | x | x | x | | | | | | | | | | | | | | -| Corsair Wireless V2 Headset Device | All | x | x | | | | | | | | | | | | | | | | +| Corsair Wireless V2 Headset Device | All | x | x | | | x | | | | | | | | | | | | | | SteelSeries Arctis (1/7X/7P) Wireless | All | x | x | | | x | | | | | | | | | | | | | | SteelSeries Arctis (7/Pro) | All | x | x | | x | x | x | | | | | | | | | | | | | SteelSeries Arctis 9 | All | x | x | | | x | x | | | | | | | | | | | | diff --git a/lib/devices/corsair_void_v2w.hpp b/lib/devices/corsair_void_v2w.hpp index 248065e..f40091a 100644 --- a/lib/devices/corsair_void_v2w.hpp +++ b/lib/devices/corsair_void_v2w.hpp @@ -16,18 +16,23 @@ using namespace std::string_view_literals; namespace headsetcontrol { /** - * @brief Corsair Void Wireless V2 + * @brief Corsair Wireless V2 headsets * * This implementation extracts the following data from HID packets: - * - Battery percentage (0-100%) which seems to be reliable up to an - * approximate 10% error + * - Battery percentage (0-100%) which seems to be reliable up to an approximate 10% error * - Device status (connected/disconnected) * - Sidetone with level mapping (0-128 to device-specific range) + * - Inactive timer (0-90 minutes range) + * - Microphone "up" status * - Query timing */ class CorsairVoidV2W : public CorsairDevice { public: - static constexpr std::array SUPPORTED_PRODUCT_IDS { 0x2a08, 0x2a02 }; + static constexpr std::array SUPPORTED_PRODUCT_IDS { + 0x2a08, // VOID WIRELESS V2 (receiver) + 0x2a02, // VIRTUOSO MAX WIRELESS (receiver) + 0x0a97 // HS80 MAX Wireless (receiver) + }; std::vector getProductIds() const override { @@ -41,8 +46,9 @@ class CorsairVoidV2W : public CorsairDevice { constexpr int getCapabilities() const override { - return B(CAP_SIDETONE) | B(CAP_BATTERY_STATUS); + return B(CAP_SIDETONE) | B(CAP_BATTERY_STATUS) | B(CAP_INACTIVE_TIME); } + // Override capability as this device needs the interface_id = 4 constexpr capability_detail getCapabilityDetail(enum capabilities cap) const override @@ -52,7 +58,8 @@ class CorsairVoidV2W : public CorsairDevice { Result getBattery(hid_device* device_handle) override { - auto init_result = initializeDevice(device_handle); + // Battery can be queried without taking over audio control + auto init_result = wakeDevice(device_handle); if (init_result.valueOr(0) == 0) { return DeviceError::deviceOffline( "Headset not connected to wireless receiver"); @@ -67,8 +74,7 @@ class CorsairVoidV2W : public CorsairDevice { 0x00, 0x02, HEADSET_ENDPOINT, 0x02, 0x0f }; - if (auto result = writeHID(device_handle, battery_request, MSG_SIZE_WRITE); - !result) { + if (auto result = writeHID(device_handle, battery_request, MSG_SIZE_WRITE); !result) { return result.error(); } std::array battery_response {}; @@ -77,60 +83,76 @@ class CorsairVoidV2W : public CorsairDevice { if (!battery_read_result) { return battery_read_result.error(); } + // Parse the Corsair battery packet + // Packet format: // [4] low byte of 1-1000 battery value // [5] high byte of 1-1000 battery value - uint8_t low_byte = battery_response[4]; - uint8_t high_byte = battery_response[5]; + // The receiver send packets with the paired headset ID instead of the + // battery level. Maybe related to multiple headset paired at the same time? + // We just re-request until we get a plausible reading... enum battery_status status = BATTERY_UNAVAILABLE; - // Extract battery level (0-100%) - // for some odd reason it doesn't always report the right value and - // instead reports this value which is the productID + 1?? - if (high_byte == 0x2a) { - // try again - if (auto result = writeHID(device_handle, battery_request, MSG_SIZE_WRITE); - !result) { + uint16_t battery_level_vendor = static_cast(battery_response[4] | (battery_response[5] << 8)); + + const uint16_t battery_level_vendor_max = 1000; + const int battery_level_max_attempts = 3; + + for (int attempt = 0; attempt < battery_level_max_attempts && + (battery_level_vendor == 0 || battery_level_vendor > battery_level_vendor_max); ++attempt) { + if (auto result = writeHID(device_handle, battery_request, MSG_SIZE_WRITE); !result) { return result.error(); } battery_read_result = readHIDTimeout(device_handle, battery_response, hsc_device_timeout); if (!battery_read_result) { return battery_read_result.error(); } - low_byte = battery_response[4]; - high_byte = battery_response[5]; - if (high_byte == 0x2a) { - - BatteryResult result { - .level_percent = -1, - .status = status, - .raw_data = std::vector(battery_response.begin(), - battery_response.end()), - }; - } + battery_level_vendor = static_cast(battery_response[4] | (battery_response[5] << 8)); } - status = BATTERY_AVAILABLE; - uint16_t battery_level_vendor = (high_byte << 8) | low_byte; + // Still out of range? report unavailable + if (battery_level_vendor == 0 || battery_level_vendor > battery_level_vendor_max) { + return BatteryResult { + .level_percent = -1, + .status = BATTERY_UNAVAILABLE, + .raw_data = std::vector(battery_response.begin(), battery_response.end()), + }; + } + + status = BATTERY_AVAILABLE; int level = static_cast(battery_level_vendor / 10); - // if there's no sidetone, the mic could be muted + // Get the microphone mute state + // These headsets physically flip the boom mic up to mute, so we map muted as MICROPHONE_UP + enum microphone_status mic_status = MICROPHONE_UNKNOWN; + + // Send mic status request + std::array mic_request { + 0x00, 0x02, HEADSET_ENDPOINT, 0x02, 0xa6 + }; + + if (auto write_result = writeHID(device_handle, mic_request, MSG_SIZE_WRITE); write_result) { + std::array mic_response {}; + if (auto mic_read = readHIDTimeout(device_handle, mic_response, hsc_device_timeout); + mic_read && mic_response[4] == 0x01) { // muted + mic_status = MICROPHONE_UP; + } + } // Build result BatteryResult result { .level_percent = level, .status = status, - .raw_data = std::vector(battery_response.begin(), - battery_response.end()), + .mic_status = mic_status, + .raw_data = std::vector(battery_response.begin(), battery_response.end()), }; return result; } - Result setSidetone(hid_device* device_handle, - uint8_t level) override + Result setSidetone(hid_device* device_handle, uint8_t level) override { // Corsair uses range 0-1000 internally in steps of 10 constexpr uint8_t CORSAIR_MIN = 0; @@ -143,11 +165,10 @@ class CorsairVoidV2W : public CorsairDevice { uint8_t high_byte = (sidetone_value >> 8) & 0xFF; auto init_result = initializeDevice(device_handle); if (init_result.valueOr(0) == 0) { - return DeviceError::deviceOffline( - "Headset not connected to wireless receiver"); + return DeviceError::deviceOffline("Headset not connected to wireless receiver"); } - // turn off ANC if sidetone is to be enabled + // Turn off ANC if sidetone is to be enabled std::array ANC_toggle { 0x00, 0x02, HEADSET_ENDPOINT, 0x01, 0xd1 }; std::array sidetone_toggle { @@ -155,9 +176,9 @@ class CorsairVoidV2W : public CorsairDevice { }; if (level == 0) { - // turn off sidetone + // Turn off sidetone sidetone_toggle[6] = 0x01; - // turn on noice cancellation which has to be off for sidetone to work + // Turn on noice cancellation which has to be off for sidetone to work ANC_toggle[6] = 0x01; } auto ANC_result = writeHID(device_handle, ANC_toggle, MSG_SIZE_WRITE); @@ -173,8 +194,7 @@ class CorsairVoidV2W : public CorsairDevice { 0x00, 0x02, HEADSET_ENDPOINT, 0x01, 0x47, 0, low_byte, high_byte }; - if (auto result = writeHID(device_handle, sidetone_data, MSG_SIZE_WRITE); - !result) { + if (auto result = writeHID(device_handle, sidetone_data, MSG_SIZE_WRITE); !result) { return result.error(); } @@ -183,8 +203,52 @@ class CorsairVoidV2W : public CorsairDevice { .min_level = 0, .max_level = 128, .device_min = CORSAIR_MIN, - .device_max = 100, // should be 1000 but the structure doesn't allow 2 - // byte numbers + .device_max = 100, // should be 1000 but the structure doesn't allow 2 byte numbers + }; + } + + Result setInactiveTime(hid_device* device_handle, uint8_t minutes) override + { + constexpr uint8_t MIN_MINUTES = 0; + constexpr uint8_t MAX_MINUTES = 90; + + if (minutes > MAX_MINUTES) { + minutes = MAX_MINUTES; + } + + auto init_result = initializeDevice(device_handle); + if (init_result.valueOr(0) == 0) { + return DeviceError::deviceOffline("Headset not connected to wireless receiver"); + } + + // Open the sleep-timer write endpoint (0x01 to enables the timer, 0x00 to disables it) + std::array open_sleep_endpoint { + 0x00, 0x02, HEADSET_ENDPOINT, 0x01, 0x0d, 0x00, + static_cast(minutes == 0 ? 0x00 : 0x01) + }; + if (auto result = writeHID(device_handle, open_sleep_endpoint, MSG_SIZE_WRITE); !result) { + return result.error(); + } + + // Set the idle timeout (in milliseconds) + if (minutes > 0) { + uint32_t timeout_ms = static_cast(minutes) * 60U * 1000U; + std::array sleep_timer { + 0x00, 0x02, HEADSET_ENDPOINT, 0x01, 0x0e, 0x00, + static_cast(timeout_ms & 0xFF), + static_cast((timeout_ms >> 8) & 0xFF), + static_cast((timeout_ms >> 16) & 0xFF), + static_cast((timeout_ms >> 24) & 0xFF) + }; + if (auto result = writeHID(device_handle, sleep_timer, MSG_SIZE_WRITE); !result) { + return result.error(); + } + } + + return InactiveTimeResult { + .minutes = minutes, + .min_minutes = MIN_MINUTES, + .max_minutes = MAX_MINUTES, }; } @@ -202,6 +266,12 @@ class CorsairVoidV2W : public CorsairDevice { }; break; + case CAP_INACTIVE_TIME: + info->parameter = CapabilityInfo::RangeParam { + .min = 0, .max = 90, .step = 1, .units = "minutes" + }; + break; + default: break; } @@ -215,42 +285,75 @@ class CorsairVoidV2W : public CorsairDevice { static constexpr uint8_t MSG_SIZE_READ = 64; static constexpr uint8_t MSG_SIZE_WRITE = 65; + Result wakeDevice(hid_device* device_handle) const + { + // wakeDevice() performs the minimal handshake required to talk to the headset + // WITHOUT switching it into software mode. It avoid producing an audible 'pop' + // sound while the device switches modes, and still let us read battery level. + + // Get firmware of receiver + std::array firmware_data { + 0x00, 0x02, RECEIVER_ENDPOINT, 0x02, 0x13 + }; + if (auto result = writeHID(device_handle, firmware_data, MSG_SIZE_WRITE); !result) { + return result.error(); + } + + // Heartbeat the receiver + std::array heartbeat_data { + 0x00, 0x02, RECEIVER_ENDPOINT, 0x02, 0x12 + }; + if (auto result = writeHID(device_handle, heartbeat_data, MSG_SIZE_WRITE); !result) { + return result.error(); + } + if (auto result = flushHIDBuffer(device_handle); !result) { + return result.error(); + } + + // Then heartbeat the headset + heartbeat_data[2] = HEADSET_ENDPOINT; + if (auto result = writeHID(device_handle, heartbeat_data, MSG_SIZE_WRITE); !result) { + return result.error(); + } + + // Returns the headset heartbeat read result + std::array heartbeat_response {}; + auto read_headset_result = readHIDTimeout(device_handle, heartbeat_response, hsc_device_timeout); + return read_headset_result; + } + Result initializeDevice(hid_device* device_handle) const { - // get firmware of receiver; neccessary for sending commands + // Get firmware of receiver; neccessary for sending commands std::array firmware_data { 0x00, 0x02, RECEIVER_ENDPOINT, 0x02, 0x13 }; - if (auto result = writeHID(device_handle, firmware_data, MSG_SIZE_WRITE); - !result) { + if (auto result = writeHID(device_handle, firmware_data, MSG_SIZE_WRITE); !result) { return result.error(); } - // turn on software mode for the receiver; neccessary for sending commands + // Turn on software mode for the receiver; neccessary for sending commands std::array software_mode_data { 0x00, 0x02, RECEIVER_ENDPOINT, 0x01, 0x03, 0x00, 0x02 }; - if (auto result = writeHID(device_handle, software_mode_data, MSG_SIZE_WRITE); - !result) { + if (auto result = writeHID(device_handle, software_mode_data, MSG_SIZE_WRITE); !result) { return result.error(); } - // send heartbeat to receiver; neccessary for sending commands + // Dend heartbeat to receiver; neccessary for sending commands std::array heartbeat_data { 0x00, 0x02, RECEIVER_ENDPOINT, 0x02, 0x12 }; - if (auto result = writeHID(device_handle, heartbeat_data, MSG_SIZE_WRITE); - !result) { + if (auto result = writeHID(device_handle, heartbeat_data, MSG_SIZE_WRITE); !result) { return result.error(); } - // repeat commands for headset + // Repeat commands for headset software_mode_data[2] = HEADSET_ENDPOINT; heartbeat_data[2] = HEADSET_ENDPOINT; - if (auto result = writeHID(device_handle, software_mode_data, MSG_SIZE_WRITE); - !result) { + if (auto result = writeHID(device_handle, software_mode_data, MSG_SIZE_WRITE); !result) { return result.error(); } @@ -258,8 +361,7 @@ class CorsairVoidV2W : public CorsairDevice { return result.error(); } - if (auto result = writeHID(device_handle, heartbeat_data, MSG_SIZE_WRITE); - !result) { + if (auto result = writeHID(device_handle, heartbeat_data, MSG_SIZE_WRITE); !result) { return result.error(); } std::array heartbeat_response {};