From 8381d1e291418fc9dd3ceb7ee53eba327125ce1e Mon Sep 17 00:00:00 2001 From: venjer Date: Wed, 19 Aug 2026 00:09:45 +0300 Subject: [PATCH] Add MCHOSE X9 Wireless (battery status) Battery status support for the MCHOSE X9 Wireless Gaming Headset (2.4GHz dongle, VID 0x3837 / PID 0x6045, C-Media chipset). Protocol reverse-engineered from a USB capture (Wireshark + USBPcap) of the official M HUB Windows app, verified against the percentage it displayed across multiple sessions and battery levels. --- README.md | 1 + lib/device_registry.cpp | 6 ++ lib/devices/mchose_x9.hpp | 119 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 lib/devices/mchose_x9.hpp diff --git a/README.md b/README.md index 894809b..596dea1 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ sudo udevadm control --reload-rules && sudo udevadm trigger | Plantronics Voyager 8200 UC (BT600) | L/W | x | x | | x | | | x | | | | | | | x | | | | | | Sony INZONE Buds | All | | x | | | | | | | | | | | | | | | | | | Sony INZONE H5 | All | x | x | | | | x | | | | | | | x | | | | | | +| MCHOSE X9 Wireless | L/W | | x | | | | | | | | | | | | | | | | | | HeadsetControl Test device | All | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | **Platform:** All = Linux, macOS, Windows | L/M = Linux and macOS only | L/W = Linux and Windows only diff --git a/lib/device_registry.cpp b/lib/device_registry.cpp index ce289b3..bfc2205 100644 --- a/lib/device_registry.cpp +++ b/lib/device_registry.cpp @@ -58,6 +58,9 @@ #include "devices/sony_inzone_buds.hpp" #include "devices/sony_inzone_h5.hpp" +// MCHOSE devices +#include "devices/mchose_x9.hpp" + // Test device #include "devices/headsetcontrol_test.hpp" @@ -157,6 +160,9 @@ void DeviceRegistry::initialize() registerDevice(std::make_unique()); registerDevice(std::make_unique()); + // MCHOSE devices + registerDevice(std::make_unique()); + // Test device registerDevice(std::make_unique()); }); diff --git a/lib/devices/mchose_x9.hpp b/lib/devices/mchose_x9.hpp new file mode 100644 index 0000000..d24d08b --- /dev/null +++ b/lib/devices/mchose_x9.hpp @@ -0,0 +1,119 @@ +#pragma once + +#include "../result_types.hpp" +#include "hid_device.hpp" +#include +#include +#include + +using namespace std::string_view_literals; + +namespace headsetcontrol { + +/** + * @brief MCHOSE X9 Wireless Gaming Headset + * + * Features: + * - Battery status + * + * Protocol reverse-engineered from a USB capture (Wireshark + USBPcap) of + * the official "M HUB" Windows app, and verified against the percentage + * that app displayed. + * + * Battery is queried on the vendor-defined collection with usage page + * 0xff90, which carries a 64-byte report 0x55 in both directions: + * + * request: 55 65 01 00 ... (message type 0x65, query battery) + * response: 55 65 VV FF ... (VV = value, FF = which field it belongs to) + */ +class MchoseX9 : public HIDDevice { +public: + static constexpr uint16_t VENDOR_MCHOSE = 0x3837; + static constexpr std::array SUPPORTED_PRODUCT_IDS { 0x6045 }; + + static constexpr uint16_t USAGE_PAGE_STATUS = 0xff90; + static constexpr uint16_t USAGE_ID_STATUS = 0x01; + static constexpr uint8_t REPORT_ID_STATUS = 0x55; + static constexpr uint8_t MSG_TYPE_VALUE = 0x65; + static constexpr uint8_t QUERY_BATTERY = 0x01; + static constexpr uint8_t FIELD_BATTERY = 0x02; + static constexpr size_t REPORT_LENGTH = 64; + + constexpr uint16_t getVendorId() const override + { + return VENDOR_MCHOSE; + } + + std::vector getProductIds() const override + { + return { SUPPORTED_PRODUCT_IDS.begin(), SUPPORTED_PRODUCT_IDS.end() }; + } + + std::string_view getDeviceName() const override + { + return "MCHOSE X9 Wireless"sv; + } + + constexpr int getCapabilities() const override + { + return B(CAP_BATTERY_STATUS); + } + + constexpr uint8_t getSupportedPlatforms() const override + { + // Untested on macOS: the dongle exposes six top-level HID collections + // and macOS opens only the first enumerated one, which is not the one + // carrying report 0x55 (see get_hid_path). + return PLATFORM_LINUX | PLATFORM_WINDOWS; + } + + // On Windows every collection of this dongle shares interface 0, so the + // usage page is what selects the right one; on Linux the whole HID + // interface is opened as a single node and the report id disambiguates. + constexpr capability_detail getCapabilityDetail([[maybe_unused]] enum capabilities cap) const override + { + return { .usagepage = USAGE_PAGE_STATUS, .usageid = USAGE_ID_STATUS, .interface_id = 0 }; + } + + Result getBattery(hid_device* device_handle) override + { + std::array request {}; + request[0] = REPORT_ID_STATUS; + request[1] = MSG_TYPE_VALUE; + request[2] = QUERY_BATTERY; + + if (auto result = writeHID(device_handle, request); !result) { + return result.error(); + } + + using clock = std::chrono::steady_clock; + const auto deadline = clock::now() + std::chrono::milliseconds { hsc_device_timeout }; + std::array response {}; + + while (true) { + const auto remaining = std::chrono::duration_cast(deadline - clock::now()); + if (remaining <= std::chrono::milliseconds::zero()) { + return DeviceError::timeout("No battery reply from the dongle"); + } + + auto read_result = readHIDTimeout(device_handle, response, static_cast(remaining.count())); + if (!read_result) { + return read_result.error(); + } + + bool is_battery_reply = *read_result >= 4 && response[0] == REPORT_ID_STATUS + && response[1] == MSG_TYPE_VALUE && response[3] == FIELD_BATTERY; + if (!is_battery_reply) { + continue; // Heartbeat or other unrelated telemetry; keep waiting + } + + return BatteryResult { + .level_percent = response[2], + .status = BATTERY_AVAILABLE, + .raw_data = std::vector { response.begin(), response.end() } + }; + } + } +}; + +} // namespace headsetcontrol