Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ src/DeviceBatteryInfo/
BatteryTrendFormatter (BatteryTrend -> display text / a normalized rate),
ChargingInference (a rising level -> Charging, for sources that cannot tell)
Sources/ one folder per backend (SystemBattery, Adb, Bluetooth, and the HID brands
Razer, Logitech, Corsair, Rapoo, Aula, Sony), each a pure parser + an IO
Razer, Logitech, Corsair, Rapoo, Aula, Sony, Mchose), each a pure parser + an IO
wrapper behind an interface + IBatterySource(+Provider);
SystemBattery has ISystemPowerReader (Windows: kernel32, macOS: pmset,
Linux: /sys/class/power_supply) and Bluetooth has IBluetoothBatteryReader
Expand Down Expand Up @@ -93,7 +93,7 @@ tests/DeviceBatteryInfo.Tests/
HidFamilyTests.cs probing, path caching, identical units, refused (unopenable) devices
HidProtocolTests.cs a second protocol on the shared HID plumbing, dongle or cable, push-only
<Brand>ProtocolTests.cs per-brand HID frames captured on real hardware (Logitech, Corsair,
Rapoo, AULA, Sony)
Rapoo, AULA, Sony, MCHOSE)
MacOsSourceTests.cs pmset and system_profiler parsers, the Bluetooth snapshot, the system
source
LinuxSourceTests.cs power_supply and BlueZ parsers, sysfs HID interface and unit keys
Expand Down Expand Up @@ -512,6 +512,18 @@ Design knowledge that is not obvious from the code alone:
`IHidTransport.GetFeatureAsync`, which only reads; `ExchangeAsync` always writes a feature first,
and writing `0x05` is something no reference implementation does. Lightbar, player LEDs, rumble
and adaptive triggers live in output report `0x02` (USB) and are not used.
- **The MCHOSE V9 Pro was mapped from the owner's USB captures, then read through the plugin's hardware
tests by the owner.** `Sources/Mchose/MchoseProtocol.cs` (dongle `291D:385D`, `FF90:0001`, 64-byte
reports) writes `55 65 01`, which is what the MCHOSE app sends, and the dongle answers `55 65` with the
level in byte 2 and the state in byte 3 within about 25 ms, without the MCHOSE app running. The level
moves in 10% steps. State `0x02` is on battery. With the cable in, the query answers `0x00` both while
charging and when full, and the frames the dongle pushes say `0x03`; the level stays frozen at its value
from when the cable went in (90 for over two hours, through to full), so both read as Charging with no
percent, as on Rapoo and AULA. Right after unplugging, the level can still be the frozen one for about
30 s before the real value arrives; the next poll picks that up. A switched-off headset leaves the
query unanswered, so the read times out; the dongle pushes `00 1A` once when it goes off, which also
throws instead of publishing 0%. On battery the dongle pushes only on changes, so the read must ask.
It also pushes `55 11 02` when the headset connects, which the reply match rejects.
- **HID on macOS goes through IOKit directly (`Sources/Hid/MacHid.cs`), not through HidSharp.** HidSharp
lists a device by `IORegistryEntryGetPath` and reopens it by `IORegistryEntryFromPath`, both capped at a
512-byte `io_string_t`; a longer path fails with `kIOReturnBadArgument` and the device silently drops out
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ listed here is not supported even if it shares a brand or protocol with one that
| Rapoo | VT3 PRO | Mouse | Dongle or cable | yes | yes | On the cable it reports only now and then, so the reading may go stale. No level while charging. |
| AULA | F75 | Keyboard | 2.4G receiver | yes | yes | No level while the cable is in, only that it charges. Not read in wired mode. |
| Sony | DualSense | Controller | USB or Bluetooth | 10% steps | yes | The controller reports its level in steps of 10%. |
| MCHOSE | V9 Pro | Headset | Dongle | 10% steps | yes | No level while charging, only that it charges. It reads nothing while the headset is off. |

The exact list is always the "Other devices" step of the config flow, which is built from the same
code. If this table and the flow ever disagree, the flow is right; please fix the table.
Expand Down Expand Up @@ -224,7 +225,7 @@ src/DeviceBatteryInfo/
Core/ IBatterySource, BatteryReading, BatteryRegistry, BatteryPollingService,
BatteryPluginOptions, DeviceCatalog, DeviceAccessProblems
Sources/ one folder per backend (SystemBattery, Adb, Bluetooth, and the HID brands Razer,
Logitech, Corsair, Rapoo, Aula, Sony): a pure parser, an IO wrapper behind an
Logitech, Corsair, Rapoo, Aula, Sony, Mchose): a pure parser, an IO wrapper behind an
interface, and an IBatterySource(+Provider).
Hid/ is the shared HID transport, base class and family; a brand adds one
protocol file whose device list is one line per supported model
Expand Down
4 changes: 3 additions & 1 deletion docs/linux-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Most of the plugin works on Linux without any setup: this computer's battery, Android phones through
Macro Deck's adb, and Bluetooth devices. **Only the USB devices from the "Other devices" catalog** (Razer,
Logitech, Corsair, Rapoo, AULA and Sony) need a one-time permission. If Macro Deck shows the problem
Logitech, Corsair, Rapoo, AULA, Sony and MCHOSE) need a one-time permission. If Macro Deck shows the problem
*"Linux needs a one-time permission to read a device"* for this plugin, this page is the fix.

## Why it is needed
Expand Down Expand Up @@ -42,6 +42,8 @@ printf '%s\n' \
'# Logitech G Pro X Superlight 2' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c54d", TAG+="uaccess"' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c09b", TAG+="uaccess"' \
'# MCHOSE V9 Pro' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="291d", ATTRS{idProduct}=="385d", TAG+="uaccess"' \
'# Rapoo VT3 PRO' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="1215", TAG+="uaccess"' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="4415", TAG+="uaccess"' \
Expand Down
2 changes: 2 additions & 0 deletions packaging/linux/70-device-battery-info.rules
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="0aba", TAG+="ua
# Logitech G Pro X Superlight 2
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c54d", TAG+="uaccess"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c09b", TAG+="uaccess"
# MCHOSE V9 Pro
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="291d", ATTRS{idProduct}=="385d", TAG+="uaccess"
# Rapoo VT3 PRO
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="1215", TAG+="uaccess"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="4415", TAG+="uaccess"
Expand Down
60 changes: 60 additions & 0 deletions src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using DeviceBatteryInfo.Core;
using DeviceBatteryInfo.Sources.Hid;

namespace DeviceBatteryInfo.Sources.Mchose;

// Report 55 65 01 asks the dongle for the battery; it answers 55 65 with the level in byte 2 (10% steps)
// and the state in byte 3. It also pushes the same frame on changes, and 55 11 when the headset connects.
internal sealed class MchoseProtocol()
: HidProtocol(
"MCHOSE",
vendorId: 0x291D,
reportLength: 64,
HidReportKind.InputOutput,
usagePage: 0xFF90,
usage: 0x0001
)
{
private const byte ReportId = 0x55;
private const byte BatteryCommand = 0x65;
private const byte Query = 0x01;
private const int LevelIndex = 2;
private const int StateIndex = 3;

private const byte OnBattery = 0x02;

// The level freezes at its value from when the cable went in, so it says nothing about the charge.
// A query answers OnCable both while charging and when full; the pushed frames say Charging.
private const byte OnCable = 0x00;
private const byte Charging = 0x03;

private const byte HeadsetOff = 0x1A;

internal static readonly byte[] BatteryRequest = [ReportId, BatteryCommand, Query];

public override IReadOnlyList<HidDeviceInfo> Devices { get; } =
[new("V9 Pro", 0x385D) { Kind = BatterySourceKind.Headset }];

public override async Task<BatteryReading> ReadAsync(
HidChannel channel,
HidDeviceInfo device,
CancellationToken cancellationToken
) => ParseBattery(await channel.ExchangeAsync(BatteryRequest, IsBatteryReport, cancellationToken));

internal static bool IsBatteryReport(byte[] report) =>
report.Length > StateIndex && report[0] == ReportId && report[1] == BatteryCommand;

internal static BatteryReading ParseBattery(ReadOnlySpan<byte> report)
{
var level = report[LevelIndex];
var state = report[StateIndex];

return state switch
{
OnCable or Charging => new BatteryReading { Status = BatteryStatus.Charging },
OnBattery when level <= 100 => BatteryReading.FromPercent(level, BatteryStatus.Discharging),
HeadsetOff => throw new InvalidOperationException("The headset is off."),
_ => throw new InvalidOperationException($"The headset reported level {level}, state {state}."),
};
}
}
52 changes: 52 additions & 0 deletions tests/DeviceBatteryInfo.Tests/HardwareTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using DeviceBatteryInfo.Sources.Aula;
using DeviceBatteryInfo.Sources.Corsair;
using DeviceBatteryInfo.Sources.Logitech;
using DeviceBatteryInfo.Sources.Mchose;
using DeviceBatteryInfo.Sources.Rapoo;
using DeviceBatteryInfo.Sources.Razer;
using DeviceBatteryInfo.Sources.SystemBattery;
Expand All @@ -29,6 +30,7 @@ public sealed class HardwareTests
new RapooProtocol(),
new AulaProtocol(),
new SonyProtocol(),
new MchoseProtocol(),
];

[SetUp]
Expand Down Expand Up @@ -149,6 +151,56 @@ public async Task Reads_every_supported_hid_device_through_the_plugin()
Assert.That(read, Is.GreaterThan(0), "No supported HID device answered.");
}

[Test]
public async Task Reads_the_raw_mchose_battery_report()
{
var protocol = new MchoseProtocol();
var transport = new HidSharpTransport(Serilog.Core.Logger.None);
var candidates = protocol
.Devices.SelectMany(d => d.ProductIds)
.SelectMany(productId => transport.FindCandidates(protocol.VendorId, productId, interfaceNumber: null, 0))
.Where(c =>
!c.CouldNotOpen
&& c.OutputReportLength >= protocol.ReportLength
&& c.HasUsage(protocol.UsagePage, protocol.Usage)
)
.ToArray();
Assume.That(candidates, Is.Not.Empty, "No MCHOSE dongle is connected.");

foreach (var candidate in candidates)
{
var subject = $"MCHOSE {DateTime.Now:HH:mm:ss}";
var channel = new HidChannel(transport, candidate.Path, candidate.ProductId, protocol.ReportKind);
try
{
var report = await channel.ExchangeAsync(
MchoseProtocol.BatteryRequest,
MchoseProtocol.IsBatteryReport,
CancellationToken.None
);
string parsed;
try
{
var reading = MchoseProtocol.ParseBattery(report);
parsed = $"{reading.Percent?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"}% {reading.Status}";
}
catch (InvalidOperationException exception)
{
parsed = exception.Message;
}

HardwareReport.Line(
subject,
$"{string.Join(" ", report.Take(16).Select(b => b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture)))} = {parsed}"
);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
HardwareReport.Line(subject, "no answer: " + exception.Message);
}
}
}

// Stands in for Razer Synapse polling the same control interface.
[Test]
public async Task Reads_while_a_foreign_poller_hammers_the_same_interface()
Expand Down
57 changes: 57 additions & 0 deletions tests/DeviceBatteryInfo.Tests/MchoseProtocolTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using DeviceBatteryInfo.Core;
using DeviceBatteryInfo.Sources.Mchose;
using NUnit.Framework;

namespace DeviceBatteryInfo.Tests;

[TestFixture]
public sealed class MchoseProtocolTests
{
// Captured from a V9 Pro dongle (USBPcap) by the device's owner, first 8 of 64 bytes.
private static readonly byte[] OnBattery = Convert.FromHexString("55655A0200000000");
private static readonly byte[] Charging = Convert.FromHexString("5565320300000000");
private static readonly byte[] OnCable = Convert.FromHexString("55655A0000000000");
private static readonly byte[] HeadsetOff = Convert.FromHexString("5565001A00000000");
private static readonly byte[] Connected = Convert.FromHexString("5511020000000000");

[Test]
public void Reads_the_level_on_battery()
{
var reading = MchoseProtocol.ParseBattery(OnBattery);

using (Assert.EnterMultipleScope())
{
Assert.That(reading.Percent, Is.EqualTo(90));
Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Discharging));
}
}

[TestCaseSource(nameof(CableFrames))]
public void On_the_cable_it_is_charging_with_no_percent(byte[] frame)
{
var reading = MchoseProtocol.ParseBattery(frame);

using (Assert.EnterMultipleScope())
{
Assert.That(reading.Percent, Is.Null);
Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Charging));
}
}

// The pushed frame while charging, and the answer to a query with the cable in (charging or full).
private static IEnumerable<byte[]> CableFrames() => [Charging, OnCable];

[Test]
public void A_headset_that_is_off_is_no_reading() =>
Assert.Throws<InvalidOperationException>(() => MchoseProtocol.ParseBattery(HeadsetOff));

[Test]
public void Only_the_battery_report_counts()
{
using (Assert.EnterMultipleScope())
{
Assert.That(MchoseProtocol.IsBatteryReport(OnBattery), Is.True);
Assert.That(MchoseProtocol.IsBatteryReport(Connected), Is.False);
}
}
}
Loading