From 146d2caf064f9cd50dd4c3cdb61bf084d49808b7 Mon Sep 17 00:00:00 2001 From: Dasio Date: Tue, 6 Oct 2026 22:33:42 +0200 Subject: [PATCH 1/2] Add HyperX Cloud Alpha 2 Wireless (03f0:08be) Battery status for the Cloud Alpha 2 Wireless: level, charging state and voltage, and DeviceOffline when the headset is powered off while the base station stays plugged in. The base station enumerates as two USB devices; commands go to the control MCU (0x08be) on its vendor interface (interface 2, usage page 0xff13, usage 0xff00) as 64-byte [command][sub-command] reports. The connection state is queried with 52 01 (reply 53 01 ) and the battery with 50 02 (reply 51 02 ...). NGENUITY polls the same interface continuously, so the driver skips ACKs, notifications and replies to other requests until its own reply arrives. Tests use replies captured from the hardware and cover connected, charging, powered off, a rejected request and a missing reply. Verified on Windows on battery, charging and with the headset off. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + lib/device_registry.cpp | 2 + lib/devices/hyperx_cloud_alpha_2_wireless.hpp | 149 +++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_hyperx_cloud_alpha_2_wireless.cpp | 172 ++++++++++++++++++ tests/test_runner.cpp | 3 + 6 files changed, 328 insertions(+) create mode 100644 lib/devices/hyperx_cloud_alpha_2_wireless.hpp create mode 100644 tests/test_hyperx_cloud_alpha_2_wireless.cpp diff --git a/README.md b/README.md index f5bf269d..50891857 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ sudo udevadm control --reload-rules && sudo udevadm trigger | SteelSeries Arctis Nova 3P Wireless | All | x | x | | | x | | | | x | x | x | | x | | | | | | | SteelSeries Arctis GameBuds | All | | x | | | | | | | | | | | | | | | | | | HyperX Cloud Alpha Wireless | All | x | x | | | x | | x | | | | | | | | | | | | +| HyperX Cloud Alpha 2 Wireless | All | | x | | | | | | | | | | | | | | | | | | HyperX Cloud Flight Wireless | All | | x | | | | | | | | | | | | | | | | | | HyperX Cloud II Wireless | All | | x | | | x | | | | | | | | | | | | | | | HyperX Cloud II Wireless (Kingston) | All | x | x | | | x | | | | | | | | | | | | | | diff --git a/lib/device_registry.cpp b/lib/device_registry.cpp index 73124607..9e6c4017 100644 --- a/lib/device_registry.cpp +++ b/lib/device_registry.cpp @@ -38,6 +38,7 @@ #include "devices/hyperx_cloud_2_wireless.hpp" #include "devices/hyperx_cloud_2_wireless_kingston.hpp" #include "devices/hyperx_cloud_3.hpp" +#include "devices/hyperx_cloud_alpha_2_wireless.hpp" #include "devices/hyperx_cloud_alpha_wireless.hpp" #include "devices/hyperx_cloud_flight.hpp" @@ -143,6 +144,7 @@ void DeviceRegistry::initialize() // HyperX devices registerDevice(std::make_unique()); + registerDevice(std::make_unique()); registerDevice(std::make_unique()); registerDevice(std::make_unique()); registerDevice(std::make_unique()); diff --git a/lib/devices/hyperx_cloud_alpha_2_wireless.hpp b/lib/devices/hyperx_cloud_alpha_2_wireless.hpp new file mode 100644 index 00000000..483f9e47 --- /dev/null +++ b/lib/devices/hyperx_cloud_alpha_2_wireless.hpp @@ -0,0 +1,149 @@ +#pragma once + +#include "../result_types.hpp" +#include "hid_device.hpp" +#include +#include +#include + +using namespace std::string_view_literals; + +namespace headsetcontrol { + +/** + * @brief HyperX Cloud Alpha 2 Wireless Gaming Headset + * + * Features: + * - Battery status (percentage, charging, voltage) + * + * The base station enumerates as two USB devices: 0x0abe (audio) and 0x08be (control MCU). + * Commands go to the control MCU's vendor interface (interface 2, usage page 0xff13 / usage 0xff00) + * as 64-byte reports laid out as [command][sub-command][payload...]. Replies use command + 1: + * 52 01 -> 53 01 + * 50 02 -> 51 02 ... + * The MCU also acknowledges every command (FF 01, command echoed at offset 14), rejects with + * FF 02, and pushes unsolicited notifications (FB xx) - e.g. FB 0A 01/00 when the headset + * links/unlinks. NGENUITY polls the same interface continuously, so replies have to be picked + * out of unrelated traffic. + * + * Hardware verification was on Windows only (on battery, charging, and with the headset powered + * off); all platforms stay declared. + * + * Vendor: HP, Inc (0x03f0) + */ +class HyperXCloudAlpha2Wireless : public HIDDevice { +public: + static constexpr uint16_t VENDOR_HP = 0x03f0; + static constexpr std::array SUPPORTED_PRODUCT_IDS { + 0x08be // Cloud Alpha 2 Wireless base station (control MCU) + }; + + static constexpr int TIMEOUT_MS = 1000; + static constexpr size_t MSG_SIZE = 64; + + static constexpr uint8_t CMD_BATTERY = 0x50; + static constexpr uint8_t SUB_BATTERY = 0x02; + static constexpr uint8_t CMD_CONNECTION = 0x52; + static constexpr uint8_t SUB_CONNECTION = 0x01; + static constexpr uint8_t REPORT_RESPONSE = 0xff; + static constexpr uint8_t RESPONSE_NACK = 0x02; + + static constexpr uint8_t CHARGE_STATE_CHARGING = 0x01; + + constexpr uint16_t getVendorId() const override + { + return VENDOR_HP; + } + + std::vector getProductIds() const override + { + return { SUPPORTED_PRODUCT_IDS.begin(), SUPPORTED_PRODUCT_IDS.end() }; + } + + std::string_view getDeviceName() const override + { + return "HyperX Cloud Alpha 2 Wireless"sv; + } + + constexpr int getCapabilities() const override + { + return B(CAP_BATTERY_STATUS); + } + + constexpr capability_detail getCapabilityDetail([[maybe_unused]] enum capabilities cap) const override + { + return { .usagepage = 0xff13, .usageid = 0xff00, .interface_id = 2 }; + } + + // Rich Results V2 API + Result getBattery(hid_device* device_handle) override + { + auto connection = query(device_handle, CMD_CONNECTION, SUB_CONNECTION); + if (!connection) { + return connection.error(); + } + if ((*connection)[2] == 0x00) { + return DeviceError::deviceOffline("Headset not connected"); + } + + auto battery = query(device_handle, CMD_BATTERY, SUB_BATTERY); + if (!battery) { + return battery.error(); + } + + const auto& r = *battery; + const int level = r[2]; + const uint8_t chg = r[3]; + if (level > 100) { + return DeviceError::protocolError("Battery level out of range"); + } + + return BatteryResult { + .level_percent = level, + .status = chg == CHARGE_STATE_CHARGING ? BATTERY_CHARGING : BATTERY_AVAILABLE, + .voltage_mv = r[6] | (r[7] << 8), + .raw_data = std::vector { r.begin(), r.begin() + 12 } + }; + } + +private: + /** + * Send [cmd][sub] and wait for the matching [cmd + 1][sub] reply, skipping ACKs, + * notifications and replies to other software's requests. + */ + Result> query(hid_device* device_handle, uint8_t cmd, uint8_t sub) const + { + std::array request {}; + request[0] = cmd; + request[1] = sub; + if (auto result = writeHID(device_handle, request); !result) { + return result.error(); + } + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(TIMEOUT_MS); + std::array response {}; + while (true) { + const auto left = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()) + .count(); + if (left <= 0) { + return DeviceError::timeout("No reply from base station"); + } + + response.fill(0); + auto read = readHIDTimeout(device_handle, response, static_cast(left)); + if (!read) { + return read.error(); + } + + if (response[0] == cmd + 1 && response[1] == sub) { + return response; + } + if (response[0] == REPORT_RESPONSE && response[1] == RESPONSE_NACK + && response[14] == cmd && response[15] == sub) { + return DeviceError::protocolError("Base station rejected the request"); + } + } + } +}; +} // namespace headsetcontrol diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 88ce025b..64c3b8fe 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,6 +15,7 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/test_library_api.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test_protocols.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test_steelseries_sidetone.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/test_hyperx_cloud_alpha_2_wireless.cpp ) # Export to parent scope diff --git a/tests/test_hyperx_cloud_alpha_2_wireless.cpp b/tests/test_hyperx_cloud_alpha_2_wireless.cpp new file mode 100644 index 00000000..faea680a --- /dev/null +++ b/tests/test_hyperx_cloud_alpha_2_wireless.cpp @@ -0,0 +1,172 @@ +#include "devices/hyperx_cloud_alpha_2_wireless.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace headsetcontrol::testing { + +class Alpha2TestFailure : public std::runtime_error { +public: + explicit Alpha2TestFailure(const std::string& message) + : std::runtime_error(message) + { + } +}; + +#define ALPHA2_ASSERT(condition, message) \ + do { \ + if (!(condition)) \ + throw Alpha2TestFailure(std::string("Assertion failed: ") + (message)); \ + } while (false) + +class Alpha2MockHID final : public HIDInterface { +public: + std::deque> reads; + std::vector> writes; + + Result write(hid_device*, std::span data) override + { + writes.emplace_back(data.begin(), data.end()); + return {}; + } + + Result write(hid_device* handle, std::span data, size_t size) override + { + std::vector padded(size); + std::copy_n(data.begin(), std::min(data.size(), size), padded.begin()); + return write(handle, padded); + } + + Result readTimeout(hid_device*, std::span data, int) override + { + if (reads.empty()) + return DeviceError::timeout("Simulated timeout"); + auto response = std::move(reads.front()); + reads.pop_front(); + const size_t size = std::min(response.size(), data.size()); + std::copy_n(response.begin(), size, data.begin()); + return size; + } + + Result sendFeatureReport(hid_device* handle, std::span data) override { return write(handle, data); } + Result sendFeatureReport(hid_device* handle, std::span data, size_t size) override { return write(handle, data, size); } + Result getFeatureReport(hid_device*, std::span) override { return DeviceError::notSupported("Not used"); } + Result getInputReport(hid_device*, std::span) override { return DeviceError::notSupported("Not used"); } +}; + +class TestableAlpha2 final : public HyperXCloudAlpha2Wireless { +public: + explicit TestableAlpha2(Alpha2MockHID& hid) + : hid_(hid) + { + } + +protected: + HIDInterface& getHIDInterface() const override { return hid_; } + +private: + Alpha2MockHID& hid_; +}; + +// ACK the base station sends for every command: FF 01, then the command echoed at offset 14 +std::vector ack(uint8_t cmd, uint8_t sub) +{ + std::vector r(64, 0); + r[0] = 0xff; + r[1] = 0x01; + r[14] = cmd; + r[15] = sub; + return r; +} + +void testBatteryConnected() +{ + Alpha2MockHID hid; + TestableAlpha2 device(hid); + hid.reads.push_back(ack(0x52, 0x01)); + hid.reads.push_back({ 0x45, 0x02, 0x01 }); // reply to NGENUITY's own polling, must be skipped + hid.reads.push_back({ 0x53, 0x01, 0x01 }); + hid.reads.push_back(ack(0x50, 0x02)); + hid.reads.push_back({ 0xfb, 0x13, 0x00, 0x5d }); // notification, must be skipped + // Captured from a real headset: 93%, not charging, 25 °C, 4193 mV + hid.reads.push_back({ 0x51, 0x02, 0x5d, 0x00, 0x19, 0x00, 0x61, 0x10, 0x01, 0x01, 0x0a }); + + auto result = device.getBattery(nullptr); + ALPHA2_ASSERT(result.hasValue(), "connected headset should report battery"); + ALPHA2_ASSERT(result->level_percent == 93, "level should be byte 2"); + ALPHA2_ASSERT(result->status == BATTERY_AVAILABLE, "not charging"); + ALPHA2_ASSERT(result->voltage_mv == 4193, "voltage should be little-endian bytes 6-7"); + ALPHA2_ASSERT(hid.writes.size() == 2, "should send connection then battery query"); + ALPHA2_ASSERT(hid.writes[0].size() == 64 && hid.writes[0][0] == 0x52 && hid.writes[0][1] == 0x01, "connection query is 52 01"); + ALPHA2_ASSERT(hid.writes[1][0] == 0x50 && hid.writes[1][1] == 0x02, "battery query is 50 02"); +} + +void testBatteryCharging() +{ + Alpha2MockHID hid; + TestableAlpha2 device(hid); + hid.reads.push_back({ 0x53, 0x01, 0x01 }); + // Captured from a real headset on USB power: 91%, charging, 28 °C, 4330 mV + hid.reads.push_back({ 0x51, 0x02, 0x5b, 0x01, 0x1c, 0x00, 0xea, 0x10, 0x01, 0x01, 0x0a }); + + auto result = device.getBattery(nullptr); + ALPHA2_ASSERT(result.hasValue(), "charging headset should report battery"); + ALPHA2_ASSERT(result->status == BATTERY_CHARGING, "charge state 1 is charging"); + ALPHA2_ASSERT(result->level_percent == 91, "level is still reported while charging"); + ALPHA2_ASSERT(result->voltage_mv == 4330, "voltage while charging"); +} + +void testHeadsetOff() +{ + Alpha2MockHID hid; + TestableAlpha2 device(hid); + hid.reads.push_back(ack(0x52, 0x01)); + hid.reads.push_back({ 0x53, 0x01, 0x00 }); + + auto result = device.getBattery(nullptr); + ALPHA2_ASSERT(result.hasError(), "powered-off headset should be an error"); + ALPHA2_ASSERT(result.error().code == DeviceError::Code::DeviceOffline, "error should be DeviceOffline"); + ALPHA2_ASSERT(hid.writes.size() == 1, "battery must not be queried when offline"); +} + +void testRejectedAndTimeout() +{ + { + Alpha2MockHID hid; + TestableAlpha2 device(hid); + auto nack = ack(0x52, 0x01); + nack[1] = 0x02; + hid.reads.push_back(nack); + auto result = device.getBattery(nullptr); + ALPHA2_ASSERT(result.hasError(), "NACK should fail the query"); + ALPHA2_ASSERT(result.error().code == DeviceError::Code::ProtocolError, "NACK is a protocol error"); + } + { + Alpha2MockHID hid; + TestableAlpha2 device(hid); + auto other_nack = ack(0x0e, 0x00); // NACK of another program's command - ignore + other_nack[1] = 0x02; + hid.reads.push_back(other_nack); + auto result = device.getBattery(nullptr); + ALPHA2_ASSERT(result.hasError(), "no reply should fail"); + ALPHA2_ASSERT(result.error().code == DeviceError::Code::Timeout, "missing reply is a timeout"); + } +} + +void runAllHyperXCloudAlpha2WirelessTests() +{ + std::cout << "\n=== HyperX Cloud Alpha 2 Wireless Tests ===" << std::endl; + testBatteryConnected(); + testBatteryCharging(); + testHeadsetOff(); + testRejectedAndTimeout(); + std::cout << " HyperX Cloud Alpha 2 Wireless tests passed" << std::endl; +} + +} // namespace headsetcontrol::testing diff --git a/tests/test_runner.cpp b/tests/test_runner.cpp index f9555c89..b576bdf1 100644 --- a/tests/test_runner.cpp +++ b/tests/test_runner.cpp @@ -27,6 +27,7 @@ void runAllStringEscapingTests(); void runAllLibraryApiTests(); void runAllProtocolTests(); void runAllSteelSeriesSidetoneTests(); +void runAllHyperXCloudAlpha2WirelessTests(); } int main() @@ -70,6 +71,8 @@ int main() headsetcontrol::testing::runAllSteelSeriesSidetoneTests(); + headsetcontrol::testing::runAllHyperXCloudAlpha2WirelessTests(); + std::cout << "\n====================================================================" << std::endl; std::cout << " All tests passed successfully! " << std::endl; std::cout << "====================================================================" << std::endl; From 92013c71d4f256f630508c2e517f6d331086c9bd Mon Sep 17 00:00:00 2001 From: Dasio Date: Tue, 6 Oct 2026 23:44:18 +0200 Subject: [PATCH 2/2] alpha 2: add sidetone, chat-mix status and equalizer presets Sidetone, the game/chat balance and the active equalizer preset live in one audio settings block on the base station: 60 02 reads it (reply 61 02), 60 01 writes the whole block back. Setters read the block first and change only their own bytes, so the other settings are kept. - sidetone: on/off at block byte 4, level 0-20 at byte 5 (0 turns it off and keeps the stored level); also reported as sidetone status - chat-mix: byte 8, 0x7b all game .. 0x80 balanced .. 0x85 all chat - equalizer preset: byte 11, presets 1-3 stored on the base station Verified on Windows: sidetone off/on and level read back from the device, chat-mix read, and each preset written and read back with the other bytes unchanged. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- lib/devices/hyperx_cloud_alpha_2_wireless.hpp | 193 ++++++++++++++++-- tests/test_hyperx_cloud_alpha_2_wireless.cpp | 137 +++++++++++++ 3 files changed, 315 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 50891857..891bc327 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ sudo udevadm control --reload-rules && sudo udevadm trigger | SteelSeries Arctis Nova 3P Wireless | All | x | x | | | x | | | | x | x | x | | x | | | | | | | SteelSeries Arctis GameBuds | All | | x | | | | | | | | | | | | | | | | | | HyperX Cloud Alpha Wireless | All | x | x | | | x | | x | | | | | | | | | | | | -| HyperX Cloud Alpha 2 Wireless | All | | x | | | | | | | | | | | | | | | | | +| HyperX Cloud Alpha 2 Wireless | All | x | x | | | | x | | | x | | | | | | | | | x | | HyperX Cloud Flight Wireless | All | | x | | | | | | | | | | | | | | | | | | HyperX Cloud II Wireless | All | | x | | | x | | | | | | | | | | | | | | | HyperX Cloud II Wireless (Kingston) | All | x | x | | | x | | | | | | | | | | | | | | diff --git a/lib/devices/hyperx_cloud_alpha_2_wireless.hpp b/lib/devices/hyperx_cloud_alpha_2_wireless.hpp index 483f9e47..f177edc1 100644 --- a/lib/devices/hyperx_cloud_alpha_2_wireless.hpp +++ b/lib/devices/hyperx_cloud_alpha_2_wireless.hpp @@ -1,9 +1,12 @@ #pragma once #include "../result_types.hpp" +#include "device_utils.hpp" #include "hid_device.hpp" +#include #include #include +#include #include using namespace std::string_view_literals; @@ -15,19 +18,29 @@ namespace headsetcontrol { * * Features: * - Battery status (percentage, charging, voltage) + * - Sidetone (mic monitoring) on/off and level + * - Chat-mix (game/chat balance) status + * - Equalizer preset (the three presets stored on the base station) * * The base station enumerates as two USB devices: 0x0abe (audio) and 0x08be (control MCU). * Commands go to the control MCU's vendor interface (interface 2, usage page 0xff13 / usage 0xff00) * as 64-byte reports laid out as [command][sub-command][payload...]. Replies use command + 1: * 52 01 -> 53 01 * 50 02 -> 51 02 ... + * 60 02 -> 61 02 <2 bytes>