From 75c80e52920eb4fa4ebefe8dd5ccf35d002cfa3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 15 Oct 2026 15:38:37 +0200 Subject: [PATCH 1/4] feat: support the MCHOSE V9 Pro headset --- AGENTS.md | 12 +++- README.md | 3 +- docs/linux-setup.md | 4 +- packaging/linux/70-device-battery-info.rules | 2 + .../Sources/Mchose/MchoseProtocol.cs | 58 +++++++++++++++++++ .../DeviceBatteryInfo.Tests/HardwareTests.cs | 2 + .../MchoseProtocolTests.cs | 53 +++++++++++++++++ 7 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs create mode 100644 tests/DeviceBatteryInfo.Tests/MchoseProtocolTests.cs diff --git a/AGENTS.md b/AGENTS.md index 252e890..577d463 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 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 @@ -512,6 +512,14 @@ 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 is mapped from the owner's USB captures, not yet read through the plugin.** + `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. The level moves in 10% steps. State `0x02` is on battery, `0x03` charging + (the level freezes at its value from when the cable went in, so charging reads with no percent, as on + Rapoo and AULA), and `0x1A` is the headset switched off (level 0, then the dongle goes silent), which + 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 diff --git a/README.md b/README.md index ca7ab5d..1199c9c 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/docs/linux-setup.md b/docs/linux-setup.md index 1e903a1..ff54781 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -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 @@ -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"' \ diff --git a/packaging/linux/70-device-battery-info.rules b/packaging/linux/70-device-battery-info.rules index 31cdac4..5838fd2 100644 --- a/packaging/linux/70-device-battery-info.rules +++ b/packaging/linux/70-device-battery-info.rules @@ -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" diff --git a/src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs b/src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs new file mode 100644 index 0000000..6faeea7 --- /dev/null +++ b/src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs @@ -0,0 +1,58 @@ +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. + private const byte Charging = 0x03; + + private const byte HeadsetOff = 0x1A; + + private static readonly byte[] BatteryRequest = [ReportId, BatteryCommand, Query]; + + public override IReadOnlyList Devices { get; } = + [new("V9 Pro", 0x385D) { Kind = BatterySourceKind.Headset }]; + + public override async Task 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 report) + { + var level = report[LevelIndex]; + var state = report[StateIndex]; + + return state switch + { + 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}."), + }; + } +} diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index 8e8b7b6..3173c43 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -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; @@ -29,6 +30,7 @@ public sealed class HardwareTests new RapooProtocol(), new AulaProtocol(), new SonyProtocol(), + new MchoseProtocol(), ]; [SetUp] diff --git a/tests/DeviceBatteryInfo.Tests/MchoseProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/MchoseProtocolTests.cs new file mode 100644 index 0000000..61fce9d --- /dev/null +++ b/tests/DeviceBatteryInfo.Tests/MchoseProtocolTests.cs @@ -0,0 +1,53 @@ +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[] 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)); + } + } + + [Test] + public void Charging_has_no_percent() + { + var reading = MchoseProtocol.ParseBattery(Charging); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reading.Percent, Is.Null); + Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Charging)); + } + } + + [Test] + public void A_headset_that_is_off_is_no_reading() => + Assert.Throws(() => 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); + } + } +} From a47d77cee1d72a764715c442cccc3b7c44168078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 15 Oct 2026 18:38:10 +0200 Subject: [PATCH 2/4] chore: add a raw MCHOSE battery report hardware test --- AGENTS.md | 8 +-- .../Sources/Mchose/MchoseProtocol.cs | 2 +- .../DeviceBatteryInfo.Tests/HardwareTests.cs | 50 +++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 577d463..306ac79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -515,9 +515,11 @@ Design knowledge that is not obvious from the code alone: - **The MCHOSE V9 Pro is mapped from the owner's USB captures, not yet read through the plugin.** `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. The level moves in 10% steps. State `0x02` is on battery, `0x03` charging - (the level freezes at its value from when the cable went in, so charging reads with no percent, as on - Rapoo and AULA), and `0x1A` is the headset switched off (level 0, then the dongle goes silent), which + in byte 3 within about 25 ms. The level moves in 10% steps. State `0x02` is on battery, read through the + plugin. `0x03` is charging in the frames the dongle pushes (the level freezes at its value from when the + cable went in, so charging reads with no percent, as on Rapoo and AULA), but the reply to the query + while charging was level 90, state `0x00`, which is not mapped yet. 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 diff --git a/src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs b/src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs index 6faeea7..1eec64e 100644 --- a/src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs @@ -28,7 +28,7 @@ internal sealed class MchoseProtocol() private const byte HeadsetOff = 0x1A; - private static readonly byte[] BatteryRequest = [ReportId, BatteryCommand, Query]; + internal static readonly byte[] BatteryRequest = [ReportId, BatteryCommand, Query]; public override IReadOnlyList Devices { get; } = [new("V9 Pro", 0x385D) { Kind = BatterySourceKind.Headset }]; diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index 3173c43..8705681 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -151,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() ?? "-"}% {reading.Status}"; + } + catch (InvalidOperationException exception) + { + parsed = exception.Message; + } + + HardwareReport.Line( + subject, + $"{string.Join(" ", report.Take(16).Select(b => b.ToString("X2")))} = {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() From d5305f3a5aa11a5187d439199062952e1f9c9f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 15 Oct 2026 23:20:38 +0200 Subject: [PATCH 3/4] fix: read the MCHOSE cable state as charging --- AGENTS.md | 16 +++++++++------- .../Sources/Mchose/MchoseProtocol.cs | 4 +++- .../MchoseProtocolTests.cs | 10 +++++++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 306ac79..5d009d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -512,13 +512,15 @@ 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 is mapped from the owner's USB captures, not yet read through the plugin.** - `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. The level moves in 10% steps. State `0x02` is on battery, read through the - plugin. `0x03` is charging in the frames the dongle pushes (the level freezes at its value from when the - cable went in, so charging reads with no percent, as on Rapoo and AULA), but the reply to the query - while charging was level 90, state `0x00`, which is not mapped yet. A switched-off headset leaves the +- **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. diff --git a/src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs b/src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs index 1eec64e..7345444 100644 --- a/src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Mchose/MchoseProtocol.cs @@ -24,6 +24,8 @@ internal sealed class MchoseProtocol() 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; @@ -49,7 +51,7 @@ internal static BatteryReading ParseBattery(ReadOnlySpan report) return state switch { - Charging => new BatteryReading { Status = BatteryStatus.Charging }, + 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}."), diff --git a/tests/DeviceBatteryInfo.Tests/MchoseProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/MchoseProtocolTests.cs index 61fce9d..c7eb495 100644 --- a/tests/DeviceBatteryInfo.Tests/MchoseProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/MchoseProtocolTests.cs @@ -10,6 +10,7 @@ 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"); @@ -25,10 +26,10 @@ public void Reads_the_level_on_battery() } } - [Test] - public void Charging_has_no_percent() + [TestCaseSource(nameof(CableFrames))] + public void On_the_cable_it_is_charging_with_no_percent(byte[] frame) { - var reading = MchoseProtocol.ParseBattery(Charging); + var reading = MchoseProtocol.ParseBattery(frame); using (Assert.EnterMultipleScope()) { @@ -37,6 +38,9 @@ public void Charging_has_no_percent() } } + // The pushed frame while charging, and the answer to a query with the cable in (charging or full). + private static IEnumerable CableFrames() => [Charging, OnCable]; + [Test] public void A_headset_that_is_off_is_no_reading() => Assert.Throws(() => MchoseProtocol.ParseBattery(HeadsetOff)); From b111d39a38e77fe3f9efcef1031869a9675dbae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 16 Oct 2026 00:05:04 +0200 Subject: [PATCH 4/4] fix: format the raw MCHOSE report with the invariant culture --- tests/DeviceBatteryInfo.Tests/HardwareTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index 8705681..8a03c81 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -182,7 +182,7 @@ public async Task Reads_the_raw_mchose_battery_report() try { var reading = MchoseProtocol.ParseBattery(report); - parsed = $"{reading.Percent?.ToString() ?? "-"}% {reading.Status}"; + parsed = $"{reading.Percent?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"}% {reading.Status}"; } catch (InvalidOperationException exception) { @@ -191,7 +191,7 @@ public async Task Reads_the_raw_mchose_battery_report() HardwareReport.Line( subject, - $"{string.Join(" ", report.Take(16).Select(b => b.ToString("X2")))} = {parsed}" + $"{string.Join(" ", report.Take(16).Select(b => b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture)))} = {parsed}" ); } catch (Exception exception) when (exception is not OutOfMemoryException)