Skip to content

[Device Request]: Add support for MCHOSE V9 Pro Wireless Headset (2.4 GHz) #24

Closed

Description

@Don-AriOn

Device

MCHOSE V9 Pro Wireless Headset (2.4 GHz)

How does the plugin process reach it?

USB HID (wired or dongle)

Battery protocol details, if you know any

I am not a native speaker. I searched for all the data using AI. The text below was entirely written by AI, using the necessary data that I managed to find with its help. I hope this information will be useful for future updates.

Device Details

  • Brand: MCHOSE
  • Model: V9 Pro (Wireless Gaming Headset)
  • Connection: 2.4 GHz USB Dongle
  • Vendor ID (VID): 0x291D
  • Product ID (PID): 0x385D

Protocol Information

The headset dongle exposes vendor-defined collections (UsagePage: 0xFF90, Usage: 0x0001). It autonomously pushes telemetry input reports (0x55, 63 bytes) over URB_INTERRUPT in.

  • Report Kind: HidReportKind.InputOutput
  • Usage Page: 0xFF90
  • Usage: 0x0001
  • Report Length: 63
  • Report ID: 0x55

Verified Packet Capture (Wireshark USBPcap)

When the headset battery is at 60%, the dongle delivers the following input report:
55 65 3c 02 00 00 00 00 00 00 00 00 00 00 00 00 ...

  • Byte 0 (0x55): Report ID
  • Byte 1 (0x65): Telemetry frame header
  • Byte 2 (0x3C): Battery percentage (0x3C = 60%)
  • Byte 3 (0x02): Device/Power status

Suggested Protocol Implementation

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using DeviceBatteryInfo.Core;
using DeviceBatteryInfo.Sources;
using DeviceBatteryInfo.Sources.Hid;

namespace DeviceBatteryInfo.Sources.Mchose;

internal sealed class MchoseProtocol() : HidProtocol(
    "MCHOSE",
    vendorId: 0x291D,
    reportLength: 63,
    reportKind: HidReportKind.InputOutput,
    usagePage: 0xFF90,
    usage: 0x0001)
{
    public override IReadOnlyList<HidDeviceInfo> Devices { get; } =
    [
        new("V9 Pro", 0x385D),
    ];

    public override async Task<BatteryReading> ReadAsync(
        HidChannel channel,
        HidDeviceInfo device,
        CancellationToken cancellationToken)
    {
        var response = await channel.ExchangeAsync(
            request: [],
            isComplete: r => r.Length >= 4 && r[0] == 0x55 && r[1] == 0x65,
            cancellationToken);

        return BatteryReading.FromPercent(response[2]);
    }
}

### Contribution

- [x] I own this device and am willing to help test a backend for it.
- [ ] I'd like to implement this myself and am opening this issue to track it.

Activity

  1. PyFlat-JR commented on Oct 15, 2026

    @PyFlat-JR
    Member

    Thanks for the capture and for offering to test! The report you found (0x55 on FF90:0001, level in byte 2) looks like a good start. Before I can add the headset, I need a few more captures, because one reading at 60% could still be a coincidence.

    Using Wireshark/USBPcap the same way you did, could you please send the full 0x55 report (all bytes, not only the first ones) in these situations:

    1. At two or three other battery levels (for example around 80% and around 30%). Please also write the level the MCHOSE software shows at that moment.
    2. While the headset is charging.
    3. When it is fully charged and still on the cable.
    4. After you switch the headset off while the dongle stays plugged in. Does the dongle still send 0x55 reports, and if so, what do they contain?

    Two more questions:

    • How often does the dongle send the 0x55 report? About every second, every few seconds, or only when something changes? The timestamps in Wireshark show this.
    • Does byte 1 always stay 0x65, or does it sometimes have other values?

    With that I can tell which bytes mean level and charging, and how long the plugin has to listen.

  2. Don-AriOn commented on Oct 15, 2026

    @Don-AriOn
    Author

    Hi! Here are the captures, detailed observations, and answers to all of your questions:

    1. Battery levels (Discharging)

    The battery level in byte 2 directly reflects the percentage 1-to-1 without conversion factors:

    • 50%: 55 65 32 02 00 00 00 00 ... (Byte 2 is 0x32 = 50%, Byte 3 is 0x02)
    • 60%: 55 65 3c 02 00 00 00 00 ... (Byte 2 is 0x3C = 60%, Byte 3 is 0x02)
    • 70%: 55 65 46 02 00 00 00 00 ... (Byte 2 is 0x46 = 70%, Byte 3 is 0x02)
    • 80%: 55 65 50 02 00 00 00 00 ... (Byte 2 is 0x50 = 80%, Byte 3 is 0x02)
      All values match the official MCHOSE desktop app readings.

    2. While charging

    • When the USB-C charging cable is plugged in, byte 3 switches from 0x02 to 0x03 (Charging indicator in the official app displays a green lightning bolt).
    • Report sample while charging:
      55 65 32 03 00 00 00 00 ...
    • Hardware quirk: The headset does not update byte 2 dynamically during charging. It freezes at the last recorded percentage (e.g. 0x32 / 50%) while byte 3 stays 0x03. Once the cable is unplugged, the controller immediately recalculates and broadcasts the new level (jumped to 0x50 / 80% with byte 3 returning to 0x02).

    3. Fully charged on cable

    Because the fuel gauge freezes byte 2 while the charging cable is connected, the level does not report 100% on-cable until disconnected. When unplugged at full charge, it follows the same direct hex mapping (0x64 = 100%).

    4. Turning the headset off (dongle plugged in)

    When the headset is powered off using its physical switch, the dongle sends a single disconnect frame:
    55 65 00 1a 00 00 00 00 ...

    • Byte 2 drops to 0x00.
    • Byte 3 becomes 0x1A (offline / link disconnected state).
    • After this frame, incoming interrupt transfers stop completely until the headset is switched back on.

    Answers to the two additional questions:

    1. How often does the dongle send the 0x55 report?

      • While charging: The dongle autonomously sends reports in bursts of two packets exactly every ~10.2 seconds.
      • While discharging: It pushes reports on state changes (power on, reconnect) and periodically when queried by the host software.
    2. Does byte 1 always stay 0x65, or does it sometimes have other values?

      • For battery and operational telemetry, byte 1 is always 0x65.
      • The only exception is an initialization handshake frame sent immediately when the headset connects: a single packet 55 11 02 00 ... arrives first, followed by regular 55 65 telemetry reports.

    Protocol Summary:

    • Collection: UsagePage 0xFF90, Usage 0x0001
    • Report ID: 0x55 (63/64 bytes payload)
    • Byte 0: 0x55 (Report ID)
    • Byte 1: 0x65 (Telemetry command)
    • Byte 2: Battery level (direct integer percentage, 0x00 to 0x64)
    • Byte 3: Status flags:
      • 0x02 = Discharging / Active
      • 0x03 = Charging
      • 0x1A = Offline / Disconnected
  3. PyFlat-JR commented on Oct 15, 2026

    @PyFlat-JR
    Member

    Thank you, this is very helpful! Level in byte 2, state in byte 3, the 00 1A frame when the headset is off, and the 55 11 frame on connect all make sense now.

    There is one problem left. You wrote that while the headset runs on battery, the dongle only sends a report when something changes, or when the MCHOSE app asks for it. The plugin cannot rely on the MCHOSE app running, so it has to ask the dongle itself, the same way the app does.

    Could you capture that request? In Wireshark:

    1. Start the capture, then open (or restart) the MCHOSE app while the headset is on battery.
    2. Look for an OUT transfer (host → dongle) on the same interface, right before a 55 65 report comes back. It is probably a URB_INTERRUPT out or a SET_REPORT control transfer.
    3. Please send all bytes of that request, and the 55 65 report that answers it.

    If the app sends several different requests when it starts, please send all of them, so I can find the one that returns the battery.

    One more small question: does the level always come in steps of 10% (50, 60, 70, 80), or have you also seen values like 57%?

  4. Don-AriOn commented on Oct 15, 2026

    @Don-AriOn
    Author

    Hi! Here are the captures of the request/response exchange when the host queries the headset on battery:

    1. The Query Request (host -> dongle)

    Sent via URB_INTERRUPT out on endpoint 0x03 (Frame 39795):

    • Length: 64 bytes
    • HID Data:
      55 65 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
      • Byte 0 = 0x55 (Report ID)
      • Byte 1 = 0x65
      • Byte 2 = 0x01 (Query command)

    2. The Response (dongle -> host)

    Received via URB_INTERRUPT in on endpoint 0x82 within ~25ms (Frame 39813):

    • Length: 64 bytes
    • HID Data:
      55 65 5a 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
      • Byte 2 = 0x5A (90% battery level)
      • Byte 3 = 0x02 (discharging)

    3. Level steps:

    The battery level strictly reports in 10% discrete steps (50%, 60%, 70%, 80%, 90% observed so far). No intermediate granular percentages (like 57%) have been seen.

  5. PyFlat-JR commented on Oct 15, 2026

    @PyFlat-JR
    Member

    Thank you, that was the missing piece!

    I've added the headset on the branch feature/mchose-v9-pro. It works like this:

    • The plugin sends 55 65 01 and reads the 55 65 answer.
    • On battery it shows the level from byte 2 (in 10% steps).
    • While charging it shows only "charging", without a percentage, because the level stays frozen at the old value until the cable is removed.
    • When the headset is off (00 1A), the plugin keeps the last value instead of showing 0%.

    Could you please test the branch? You need the .NET 10 SDK and a clone of the branch. Then, from tests/DeviceBatteryInfo.Tests, run:

    dotnet test --filter "Category=Hardware&Name=Reads_every_supported_hid_device_through_the_plugin" --logger "console;verbosity=detailed"
    

    Please close the MCHOSE app first, so we know the plugin gets an answer on its own. The output should contain a line for MCHOSE V9 Pro. Please run it in these situations and send the output each time:

    1. On battery. Please also write the level the MCHOSE app shows.
    2. While charging.
    3. With the headset switched off (dongle still plugged in).

    If you also use Macro Deck, you can build the plugin from the branch and add the headset under "Other devices" → MCHOSE → V9 Pro. Then please tell me if the level and the charging state look right over a longer time.

  6. Don-AriOn commented on Oct 15, 2026

    @Don-AriOn
    Author

    Hi! Here is the full console output from testing the feature/mchose-v9-pro branch using the hardware test command:


    1. ON BATTERY (Discharging)

    • MCHOSE app reading: 90%
    • Test verdict: PASSED
    ........
    AULA F75                                     not connected
    Sony DualSense                               not connected
    MCHOSE V9 Pro                                90% Discharging
    
      Passed Reads_every_supported_hid_device_through_the_plugin [359 ms]
    

    Note: The active query (55 65 01) works completely fine on its own without the official application running.


    2. WHILE CHARGING (Type-C cable plugged in)

    • Test verdict: FAILED
    ........
    AULA F75                                     not connected
    Sony DualSense                               not connected
    MCHOSE V9 Pro                                read failed: The headset reported level 90, state 0.
    
      Failed Reads_every_supported_hid_device_through_the_plugin [321 ms]
      Error Message:
         No supported HID device answered.
    Assert.That(read, Is.GreaterThan(0))
      Expected: greater than 0
      But was:  0
      Stack Trace:
         at DeviceBatteryInfo.Tests.HardwareTests.Reads_every_supported_hid_device_through_the_plugin() in tests/DeviceBatteryInfo.Tests/HardwareTests.cs:line 151
    

    Note: When queried while charging, the headset responded with level 90, but state was parsed as 0, triggering "read failed: The headset reported level 90, state 0".


    3. HEADSET SWITCHED OFF (Dongle remains plugged in)

    • Test verdict: FAILED (Expected timeout)
    ........
    AULA F75                                     not connected
    Sony DualSense                               not connected
    MCHOSE V9 Pro                                read failed: HID report exchange on \\?\hid#vid_291d&pid_385d&mi_00&col05#9&11c406c9&0&0004#{4d1e55b2-f16f-11cf-88cb-001111000030} was not answered within 2000 ms.
    
      Failed Reads_every_supported_hid_device_through_the_plugin [2 s]
      Error Message:
         No supported HID device answered.
    Assert.That(read, Is.GreaterThan(0))
      Expected: greater than 0
      But was:  0
      Stack Trace:
         at DeviceBatteryInfo.Tests.HardwareTests.Reads_every_supported_hid_device_through_the_plugin() in tests/DeviceBatteryInfo.Tests/HardwareTests.cs:line 151
    

    Note: Because the headset is powered off, it does not respond to the active request, which results in the expected 2000 ms timeout.

  7. Don-AriOn commented on Oct 15, 2026

    @Don-AriOn
    Author

    I posted the test results above, but also tried testing the built plugin directly inside Macro Deck 3 as you suggested.

    Unfortunately, running our locally built .dll in Macro Deck 3 causes the configuration dialog to open completely blank (the form fields and device selection dropdown do not load). This appears to be caused by a runtime/.NET version mismatch when loading the locally built library, as Macro Deck 3 fails to instantiate the plugin's configuration UI.

    Additions from a real person: Or maybe I just didn't understand how to properly communicate to the AI ​​what needed to be done. I previously tried manually adding support for my audio headset to your plugin, but I ran into the same problem (the settings window disappeared, was blank, and broke the plugin's logic). So I decided to write to you directly and provide all the necessary information

    Since the console test (dotnet test) directly accesses the hardware and already gave clear results:

    1. On battery: Successfully queried and reported 90% Discharging via 55 65 01.
    2. While charging: Failed with read failed: The headset reported level 90, state 0.
    3. Switched off: Timed out as expected.

    Could you please check the state 0 parsing issue during charging on the branch? Once you create an official test build / .macroDeckPlugin release package, I'll be happy to install it directly in Macro Deck and test it over a longer period!

  8. PyFlat-JR commented on Oct 15, 2026

    @PyFlat-JR
    Member

    Thank you for testing! On battery it works without the MCHOSE app, and switched off it keeps the last value, which is what it should do.

    Charging: the headset answers the plugin's request with state 0x00 while charging, not 0x03 like in the reports the dongle sends on its own. Before I map that, I'd like to see the raw answer over time. I added a test on the branch that prints the raw bytes. I rewrote the branch, so please update it with git fetch and git reset --hard origin/feature/mchose-v9-pro (a plain git pull will fail). Then run from tests/DeviceBatteryInfo.Tests:

    dotnet test --filter "Category=Hardware&Name=Reads_the_raw_mchose_battery_report" --logger "console;verbosity=detailed"
    

    Please run it:

    1. Once on battery.
    2. Right after plugging in the cable, then every 5 minutes for about half an hour while it charges (does the level change?).
    3. Once when the MCHOSE app says it is fully charged, with the cable still in.
    4. Right after unplugging the cable again.

    Please write the level the MCHOSE app shows next to each run.

    Macro Deck: a blank configuration window usually means the plugin was not installed the way Macro Deck expects, not that something is wrong with the .NET version. How exactly did you put the plugin into Macro Deck? Did you copy the .dll somewhere, or install a package?

    The easiest way to try the branch in Macro Deck is the Makefile in the repository. You need GNU make and Git Bash on your PATH (on Windows). Then, with Macro Deck 3 running, from the repository folder:

    make cli
    make run
    

    make cli installs the matching macrodeck-plugin tool, and make run builds the plugin and connects it to your running Macro Deck. The first time, Macro Deck asks you to pair it. You don't need to copy any files. If the window is still blank that way, please send the Macro Deck log from that time.

  9. Don-AriOn commented on Oct 15, 2026

    @Don-AriOn
    Author

    Hi! Here are the full test results covering all the requested scenarios:

    Summary of the Logging Timeline

    Timestamp Physical State Headset LED Raw HID Report Parsed Result
    21:48:55 On battery (before cable) Normal 55 65 5A 02 00 00 00... 90% Discharging
    21:53:25 Cable plugged in (0 min) Red 55 65 5A 00 00 00 00... level 90, state 0
    22:01:17 Charging (~8 min) Red 55 65 5A 00 00 00 00... level 90, state 0
    22:09:27 Charging (~16 min) Red 55 65 5A 00 00 00 00... level 90, state 0
    22:14:46 Charging (~21 min) Red 55 65 5A 00 00 00 00... level 90, state 0
    22:19:59 Charging (~26 min) Red 55 65 5A 00 00 00 00... level 90, state 0
    23:06:49 Charging (~73 min) Red 55 65 5A 00 00 00 00... level 90, state 0
    00:12:05 Fully charged on cable Blue 55 65 5A 00 00 00 00... level 90, state 0
    00:14:12 Right after unplugging Blue/Normal 55 65 5A 02 00 00 00... 90% Discharging
    00:14:48 ~30s after unplugging and clicking the refresh button in the app. Normal 55 65 64 02 00 00 00... 100% Discharging

    Key Observations

    1. State 0x00 strictly means Charging:
      While the cable is connected, the active query response consistently returns byte 3 as 0x00. Bytes 4 through 15 are always 0x00.
    2. Battery Level is frozen while plugged in:
      Even when the hardware finishes charging (LED turns from Red to Blue), byte 2 remains frozen at the pre-charging value (0x5A / 90%) and state stays 0x00. The official MCHOSE app also displays 90% until the cable is removed.
    3. Transition to 100%:
      A few seconds after disconnecting the Type-C cable, the headset switches back to state 0x02 (Discharging) and updates byte 2 to 0x64 (100%).

    Detailed Console Logs

    1. Fully charged with cable still plugged in (LED turned Blue, MCHOSE app showing 90%):
    MCHOSE 00:12:05: 55 65 5A 00 00 00 00 00 00 00 00 00 00 00 00 00 = The headset reported level 90, state 0.
    Passed Reads_the_raw_mchose_battery_report [247 ms]

    2. Right after unplugging the cable (immediate query):
    MCHOSE 00:14:12: 55 65 5A 02 00 00 00 00 00 00 00 00 00 00 00 00 = 90% Discharging
    Passed Reads_the_raw_mchose_battery_report [243 ms]

    3. ~30 seconds after unplugging (battery gauge refreshed to true value):
    MCHOSE 00:14:48: 55 65 64 02 00 00 00 00 00 00 00 00 00 00 00 00 = 100% Discharging
    Passed Reads_the_raw_mchose_battery_report [178 ms]

    Author's observations

    I am very confused by the fact that 100% in the application and 0x64 in the console were displayed only after I clicked the Refresh button in the official application. It might have been worth waiting longer, but when I saw that after disconnecting the cable from the headset, the indication in the application disappeared that charging was currently underway, and only 90% remained, it puzzled me and I was confused and just updated the data manually. The AI calms me down and says that the headset itself does not send requests all the time, and by pressing the button I just sent the same request as in the test "55 65 01", but the first time the answer came "55 65 5A 02..."
    If the manual update allegedly sent the same request, then why was the answer different last time? Honestly, I don't know. It may be a coincidence, but I won't be able to check again soon. The headset has a very capacious battery (2000 mAh) and it will take several days to discharge it to values at which global tests can be repeated)))

  10. PyFlat-JR commented on Oct 15, 2026

    @PyFlat-JR
    Member

    Thank you for the long test, this answers it! With the cable in, the headset always answers state 0x00 with the frozen level, both while charging and when it is full. So the plugin now shows 0x00 as "charging" without a percentage, the same as 0x03. It cannot tell charging and full apart, because the headset doesn't either.

    About the 90% → 100% after unplugging: I don't think the refresh button changed anything. Right after the cable comes out, the headset still reports the old frozen level for a short time, and then updates it. Your refresh came after that update, so it showed 100%. The plugin asks the headset again on every poll, so it will pick up the new value by itself. No need to repeat the test for that.

    The change is on the branch; a git pull gets it. If you still run into problems with Macro Deck, just tell me and we'll fix them.

    If you don't find any other issues, just give me your go and I'll merge it.

  11. Don-AriOn commented on Oct 15, 2026

    @Don-AriOn
    Author
    Image Image Image

    I built and packed the plugin locally, installed it into Macro Deck 3, and tested the widget in all real scenarios. It works like a charm!

    Here is what I tested:

    1. Normal wireless mode: Displays clean battery percentage (100%) with the active green gauge.
    2. Plugged in (Charging): Displays the charging icon, changes the state label to charging, and shows -- instead of misleading frozen percentage values.
    3. Turned off: Correctly switches to the grayed-out no signal state, and immediately recovers back to normal once the headset is turned back on.

    Everything behaves as expected. You have my full go to merge the branch into main and release it!

    Thank you again for the quick implementation and guidance throughout the process!

  12. PyFlat-JR commented on Oct 15, 2026

    @PyFlat-JR
    Member

    Great, thank you for testing it so thoroughly, and for the screenshots! The pull request is #28.

    Even once it is merged, it will probably still take a few days until it shows up in a release: I want to make one full release together with the other devices that are in progress right now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions