From b32dd91aca085b791f965728ba00ffef84738aa7 Mon Sep 17 00:00:00 2001 From: Manuel Mayer <45011179+manuelmayer-dev@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:33:57 +0200 Subject: [PATCH 1/2] fix: read the G Pro X Wireless battery through G Hub's own curves (#1) HeadsetControl's G Pro points read 3924 mV as 65% while G Hub showed 79%, and any charging voltage as 100% while G Hub showed 87%. G Hub ships a power model for the 0ABA (pro_x_wireless_headset_battery.xml) with a discharge curve and a charge curve; the headset now reads through points sampled from both. The charge curve rises to about 4275 mV at 87%, then falls while the battery fills to 100%, so a charge is followed per device path: the turn is a drop of 5 mV below the peak, and the reading never goes down during one charge. The hardware test that prints the raw voltage now covers the headset too. --- AGENTS.md | 14 +- .../Sources/Hid/HidProtocol.cs | 2 + .../Logitech/LogitechHeadsetProtocol.cs | 158 ++++++++++++++++-- .../Sources/Logitech/LogitechHidppProtocol.cs | 8 +- .../DeviceBatteryInfo.Tests/HardwareTests.cs | 58 +++++-- .../LogitechHeadsetProtocolTests.cs | 59 ++++++- 6 files changed, 263 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b029a97..47f39f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -416,9 +416,17 @@ Design knowledge that is not obvious from the code alone: headsets). Verified on a G Pro X Wireless `0ABA`: same HID++, but on `FF43:0202`, addressed as the receiver (device index `0xFF`), and they have no battery feature at all - only `0x1F20` (ADC measurement), which answers a voltage in mV, not a percentage, plus a state byte where - `0x03` means charging. The mV are mapped through HeadsetControl's G Pro calibration points, linearly - interpolated; that curve is an estimate, so treat the percentage as approximate and never as a value - to calibrate other code against. A powered-off headset reads far below the curve, which is why + `0x03` means charging. The mV are mapped through points sampled from G Hub's own power model for the + headset (`C:\ProgramData\LGHUB\depots\\pro_headset\pro_x_wireless_headset_battery.xml`, voltage + against mAh drawn), linearly interpolated: 3924 mV read 82% against G Hub's 79%, where HeadsetControl's + G Pro points, used before, read 65%. G Hub's other devices may ship such a file too; look there before + taking a curve from elsewhere. It is still an estimate, never a value to calibrate other code against. + On the cable (state `0x03`) the voltage reads high (4281 mV while G Hub showed 87%), so a charge uses + the same file's charge curve instead: rising to about 4275 mV at 87%, then falling to about 4206 mV + while the battery fills to 100%. A voltage alone cannot tell the two parts apart, so + `LogitechHeadsetCharge` follows one charge per device path (the turn is a drop of 5 mV below the + peak, the reading never goes down) and is forgotten on the first reading off the charger. + A powered-off headset reads far below the curve, which is why `ParseVoltage` throws there instead of publishing 0%. Older Lightspeed mice (the G502 Lightspeed, `LogitechVoltageBatteryProtocol`) have only `0x1001` (battery voltage): mV big-endian in bytes 4-5, then a flags byte where bit 7 is external power and, only while it is set, bits 0-2 are the charge diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs b/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs index 675e547..df1fc4b 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs @@ -32,6 +32,8 @@ internal sealed class HidChannel( public int ProductId { get; } = productId; + public string DevicePath { get; } = devicePath; + // Returns the first response isComplete accepts. Throws when none does, so the poll keeps // the last good value. public Task ExchangeAsync( diff --git a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs index 9cea400..189358c 100644 --- a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using DeviceBatteryInfo.Core; using DeviceBatteryInfo.Sources.Hid; @@ -13,15 +14,42 @@ internal sealed class LogitechHeadsetProtocol() private const byte Charging = 0x03; - // Discharge curve from HeadsetControl's G Pro calibration, highest voltage first. + // The bottom of the discharge curve; a headset that is off reads below it. + internal const int OffMillivolts = 3300; + + // Discharge curve sampled from G Hub's own power model for the 0ABA + // (pro_x_wireless_headset_battery.xml: voltage against mAh drawn, 1271 mAh in all), highest voltage + // first. HeadsetControl's G Pro points, used before, read 3924 mV as 65% where G Hub showed 79%. private static readonly (int Millivolts, int Percent)[] Curve = [ - (4150, 100), - (3830, 50), - (3780, 30), - (3740, 20), - (3670, 5), - (3320, 0), + (4100, 100), + (4080, 98), + (4060, 97), + (4040, 94), + (4020, 92), + (4000, 91), + (3980, 89), + (3960, 86), + (3940, 84), + (3920, 81), + (3900, 78), + (3880, 75), + (3860, 73), + (3840, 70), + (3820, 67), + (3800, 63), + (3780, 58), + (3760, 51), + (3740, 42), + (3720, 35), + (3700, 28), + (3680, 24), + (3660, 20), + (3640, 15), + (3600, 12), + (3500, 8), + (3400, 4), + (OffMillivolts, 0), ]; // ADC measurement: the one battery feature these headsets expose. @@ -33,32 +61,136 @@ private static readonly (int Millivolts, int Percent)[] Curve = public override IReadOnlyList Devices { get; } = [new("G Pro X Wireless", 0x0ABA) { Kind = BatterySourceKind.Headset }]; + // One charge in progress per device path; a reading off the charger ends it. + private readonly ConcurrentDictionary _charges = new(); + protected override BatteryReading ParseBattery(ReadOnlySpan report) => ParseVoltage(report); - internal static BatteryReading ParseVoltage(ReadOnlySpan report) + protected override BatteryReading ParseBattery(HidChannel channel, ReadOnlySpan report) + { + if (report.Length < 7 || report[6] != Charging) + { + _charges.TryRemove(channel.DevicePath, out _); + return ParseVoltage(report); + } + + return ParseVoltage(report, _charges.GetOrAdd(channel.DevicePath, _ => new())); + } + + // Without a charge to follow, a charging headset reads from the rising part of the charge curve. + internal static BatteryReading ParseVoltage( + ReadOnlySpan report, + LogitechHeadsetCharge? charge = null + ) { ThrowOnError(report, "reading the battery voltage"); var millivolts = report[4] << 8 | report[5]; // A powered-off headset reads far below the curve, which would show as a real 0%. - if (millivolts < Curve[^1].Millivolts) + if (millivolts < OffMillivolts) { throw new InvalidOperationException($"The headset reported {millivolts} mV: it is off."); } + if (report[6] == Charging) + { + return new BatteryReading + { + Percent = + charge?.Update(millivolts) + ?? LogitechHidppProtocol.PercentFromMillivolts( + millivolts, + LogitechHeadsetCharge.Rising + ), + Status = BatteryStatus.Charging, + }; + } + var percent = PercentFromMillivolts(millivolts); return new BatteryReading { Percent = percent, - Status = - report[6] == Charging ? BatteryStatus.Charging - : percent >= 100 ? BatteryStatus.Full - : BatteryStatus.Discharging, + Status = percent >= 100 ? BatteryStatus.Full : BatteryStatus.Discharging, }; } internal static int PercentFromMillivolts(int millivolts) => LogitechHidppProtocol.PercentFromMillivolts(millivolts, Curve); } + +// The charging voltage reads high, so G Hub has a charge curve of its own (same file as the discharge +// curve): it rises to about 4275 mV at 87% (constant current), then falls to about 4206 mV while the +// battery fills to 100% (constant voltage). A voltage alone cannot tell the two apart, so this follows +// one charge: rising until the voltage has dropped below its peak, falling after that, and never lower +// than it already read. A plugin restart late in a charge reads the rising part until the next drop. +internal sealed class LogitechHeadsetCharge +{ + // Highest voltage first, as PercentFromMillivolts expects. + internal static readonly (int Millivolts, int Percent)[] Rising = + [ + (4275, 87), + (4250, 84), + (4224, 80), + (4199, 76), + (4173, 72), + (4147, 67), + (4122, 61), + (4097, 55), + (4072, 48), + (4046, 39), + (4021, 26), + (3996, 19), + (3971, 15), + (3946, 11), + (3921, 6), + (3895, 5), + (3836, 4), + (3806, 3), + (3700, 2), + (3590, 1), + (3444, 0), + ]; + + internal static readonly (int Millivolts, int Percent)[] Falling = + [ + (4273, 87), + (4264, 88), + (4257, 90), + (4249, 91), + (4241, 93), + (4234, 94), + (4227, 95), + (4219, 97), + (4212, 98), + (4206, 100), + // Lower still is full too, never the 0 below the end of a curve. + (LogitechHeadsetProtocol.OffMillivolts, 100), + ]; + + // A drop this far below the peak is the turn, not noise between two reads. + private const int PeakSlack = 5; + + private readonly Lock _lock = new(); + private int _peak; + private bool _falling; + private int _percent; + + public int Update(int millivolts) + { + lock (_lock) + { + // Only the falling part's range can be the turn; lower down the voltage still rises. + _falling |= _peak >= Falling[^2].Millivolts && millivolts <= _peak - PeakSlack; + _peak = Math.Max(_peak, millivolts); + + var percent = LogitechHidppProtocol.PercentFromMillivolts( + millivolts, + _falling ? Falling : Rising + ); + _percent = Math.Max(_percent, percent); + return _percent; + } + } +} diff --git a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs index 268bdea..ed51054 100644 --- a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs @@ -31,6 +31,10 @@ internal abstract class LogitechHidppProtocol(int usagePage, int usage, byte dev protected abstract BatteryReading ParseBattery(ReadOnlySpan report); + // For a product line that keeps state per device between reads. + protected virtual BatteryReading ParseBattery(HidChannel channel, ReadOnlySpan report) => + ParseBattery(report); + public sealed override async Task ReadAsync( HidChannel channel, HidDeviceInfo device, @@ -50,7 +54,7 @@ CancellationToken cancellationToken response => IsReplyTo(response, deviceIndex, featureIndex, function), cancellationToken ); - return ParseBattery(batteryResponse); + return ParseBattery(channel, batteryResponse); } internal static byte Function(byte functionId) => (byte)(functionId << 4 | SoftwareId); @@ -108,7 +112,7 @@ internal static int PercentFromMillivolts( { if (millivolts >= curve[0].Millivolts) { - return 100; + return curve[0].Percent; } for (var i = 1; i < curve.Length; i++) diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index 8c04f3b..f67d028 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -390,22 +390,26 @@ private static async Task CenturionFeaturesAsync(Func ); } - private sealed class LogitechVoltageProbe() : LogitechHidppProtocol(0xFF00, 0x0002, 0x01) + private interface IVoltageProbe + { + int Millivolts { get; } + + byte Flags { get; } + } + + private sealed class LogitechHeadsetVoltageProbe() + : LogitechHidppProtocol(0xFF43, 0x0202, LogitechHeadsetProtocol.DeviceIndex), IVoltageProbe + { + private readonly LogitechHeadsetProtocol _protocol = new(); + + public int Millivolts { get; private set; } + + public byte Flags { get; private set; } + + public override IReadOnlyList Devices => _protocol.Devices; + + protected override ushort BatteryFeature => 0x1F20; + + protected override byte BatteryFunction => 0x00; + + protected override BatteryReading ParseBattery(ReadOnlySpan report) + { + Millivolts = report[4] << 8 | report[5]; + Flags = report[6]; + return LogitechHeadsetProtocol.ParseVoltage(report); + } + } + + private sealed class LogitechVoltageProbe() : LogitechHidppProtocol(0xFF00, 0x0002, 0x01), IVoltageProbe { private readonly LogitechVoltageBatteryProtocol _protocol = new(); diff --git a/tests/DeviceBatteryInfo.Tests/LogitechHeadsetProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/LogitechHeadsetProtocolTests.cs index fa3f4c3..40bbc35 100644 --- a/tests/DeviceBatteryInfo.Tests/LogitechHeadsetProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LogitechHeadsetProtocolTests.cs @@ -38,7 +38,7 @@ public void The_recorded_answers_give_feature_index_six_and_four_volts() using (Assert.EnterMultipleScope()) { Assert.That(LogitechHidppProtocol.ParseFeatureIndex(FeatureAnswer), Is.EqualTo(6)); - Assert.That(reading.Percent, Is.EqualTo(78)); + Assert.That(reading.Percent, Is.EqualTo(91)); Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Discharging)); } } @@ -54,6 +54,52 @@ public void State_three_is_reported_as_charging() ); } + [Test] + public void A_charging_headset_reads_from_the_charge_curve() + { + // 4281 mV on the cable, read while G Hub showed 87%. + var charging = LogitechHeadsetProtocol.ParseVoltage(Frame("11FF060110B90300")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(charging.Percent, Is.EqualTo(87)); + Assert.That(charging.Status, Is.EqualTo(BatteryStatus.Charging)); + } + } + + [Test] + public void A_charge_rises_turns_after_its_peak_and_never_reads_lower() + { + var charge = new LogitechHeadsetCharge(); + + int[] readings = + [ + charge.Update(4097), + charge.Update(4199), + charge.Update(4275), + charge.Update(4273), // noise at the peak, not the turn yet + charge.Update(4264), + charge.Update(4241), + charge.Update(4245), // a falling voltage that ticks up again reads no lower + charge.Update(4206), + ]; + + Assert.That(readings, Is.EqualTo(new[] { 55, 76, 87, 87, 88, 93, 93, 100 })); + } + + [Test] + public void A_dip_low_on_the_rising_part_is_not_taken_for_the_turn() + { + var charge = new LogitechHeadsetCharge(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(charge.Update(4100), Is.EqualTo(56)); + Assert.That(charge.Update(4090), Is.EqualTo(56)); + Assert.That(charge.Update(4199), Is.EqualTo(76)); + } + } + [Test] public void A_voltage_below_the_curve_throws_instead_of_reading_as_empty() { @@ -104,11 +150,12 @@ public void Reports_for_other_features_are_not_taken_for_the_answer() } [TestCase(4200, 100)] - [TestCase(4150, 100)] - [TestCase(3990, 75)] - [TestCase(3830, 50)] - [TestCase(3740, 20)] - [TestCase(3320, 0)] + [TestCase(4100, 100)] + [TestCase(3990, 90)] + [TestCase(3924, 82)] // read on the headset while G Hub showed 79% + [TestCase(3760, 51)] + [TestCase(3550, 10)] + [TestCase(3300, 0)] public void The_curve_interpolates_between_its_calibration_points(int millivolts, int percent) { Assert.That(LogitechHeadsetProtocol.PercentFromMillivolts(millivolts), Is.EqualTo(percent)); From 146138516d5025045fec33aee4cd2771044b82a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 16 Oct 2026 22:56:37 +0200 Subject: [PATCH 2/2] chore: trim the headset curve comments and read the channel path through its property --- .../Sources/Hid/HidProtocol.cs | 6 +++--- .../Logitech/LogitechHeadsetProtocol.cs | 20 +++++-------------- .../Sources/Logitech/LogitechHidppProtocol.cs | 1 - .../LogitechHeadsetProtocolTests.cs | 4 ++-- 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs b/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs index df1fc4b..097e08e 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs @@ -43,14 +43,14 @@ CancellationToken cancellationToken ) => kind == HidReportKind.Feature ? transport.ExchangeAsync( - devicePath, + DevicePath, request, isComplete, budget ?? ReadBudget, cancellationToken ) : transport.ExchangeReportsAsync( - devicePath, + DevicePath, request, isComplete, budget ?? ReadBudget, @@ -58,7 +58,7 @@ CancellationToken cancellationToken ); public Task GetFeatureAsync(byte reportId, CancellationToken cancellationToken) => - transport.GetFeatureAsync(devicePath, reportId, budget ?? ReadBudget, cancellationToken); + transport.GetFeatureAsync(DevicePath, reportId, budget ?? ReadBudget, cancellationToken); } // Subclass it, list the devices and implement ReadAsync. Finding the device is done for you. diff --git a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs index 189358c..330cf2b 100644 --- a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs @@ -14,12 +14,9 @@ internal sealed class LogitechHeadsetProtocol() private const byte Charging = 0x03; - // The bottom of the discharge curve; a headset that is off reads below it. internal const int OffMillivolts = 3300; - // Discharge curve sampled from G Hub's own power model for the 0ABA - // (pro_x_wireless_headset_battery.xml: voltage against mAh drawn, 1271 mAh in all), highest voltage - // first. HeadsetControl's G Pro points, used before, read 3924 mV as 65% where G Hub showed 79%. + // Sampled from G Hub's power model for the 0ABA (pro_x_wireless_headset_battery.xml). private static readonly (int Millivolts, int Percent)[] Curve = [ (4100, 100), @@ -61,7 +58,6 @@ private static readonly (int Millivolts, int Percent)[] Curve = public override IReadOnlyList Devices { get; } = [new("G Pro X Wireless", 0x0ABA) { Kind = BatterySourceKind.Headset }]; - // One charge in progress per device path; a reading off the charger ends it. private readonly ConcurrentDictionary _charges = new(); protected override BatteryReading ParseBattery(ReadOnlySpan report) => @@ -78,7 +74,6 @@ protected override BatteryReading ParseBattery(HidChannel channel, ReadOnlySpan< return ParseVoltage(report, _charges.GetOrAdd(channel.DevicePath, _ => new())); } - // Without a charge to follow, a charging headset reads from the rising part of the charge curve. internal static BatteryReading ParseVoltage( ReadOnlySpan report, LogitechHeadsetCharge? charge = null @@ -120,14 +115,10 @@ internal static int PercentFromMillivolts(int millivolts) => LogitechHidppProtocol.PercentFromMillivolts(millivolts, Curve); } -// The charging voltage reads high, so G Hub has a charge curve of its own (same file as the discharge -// curve): it rises to about 4275 mV at 87% (constant current), then falls to about 4206 mV while the -// battery fills to 100% (constant voltage). A voltage alone cannot tell the two apart, so this follows -// one charge: rising until the voltage has dropped below its peak, falling after that, and never lower -// than it already read. A plugin restart late in a charge reads the rising part until the next drop. +// G Hub's charge curve rises to ~4275 mV at 87%, then falls while the battery fills, so one charge is +// followed past its peak; a voltage alone cannot tell the two parts apart. internal sealed class LogitechHeadsetCharge { - // Highest voltage first, as PercentFromMillivolts expects. internal static readonly (int Millivolts, int Percent)[] Rising = [ (4275, 87), @@ -165,11 +156,10 @@ internal static readonly (int Millivolts, int Percent)[] Falling = (4219, 97), (4212, 98), (4206, 100), - // Lower still is full too, never the 0 below the end of a curve. + // Lower still is full, not the 0 below a curve's end. (LogitechHeadsetProtocol.OffMillivolts, 100), ]; - // A drop this far below the peak is the turn, not noise between two reads. private const int PeakSlack = 5; private readonly Lock _lock = new(); @@ -181,7 +171,7 @@ public int Update(int millivolts) { lock (_lock) { - // Only the falling part's range can be the turn; lower down the voltage still rises. + // Lower down the voltage still rises, so only the falling part's range can be the turn. _falling |= _peak >= Falling[^2].Millivolts && millivolts <= _peak - PeakSlack; _peak = Math.Max(_peak, millivolts); diff --git a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs index ed51054..a751296 100644 --- a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs @@ -31,7 +31,6 @@ internal abstract class LogitechHidppProtocol(int usagePage, int usage, byte dev protected abstract BatteryReading ParseBattery(ReadOnlySpan report); - // For a product line that keeps state per device between reads. protected virtual BatteryReading ParseBattery(HidChannel channel, ReadOnlySpan report) => ParseBattery(report); diff --git a/tests/DeviceBatteryInfo.Tests/LogitechHeadsetProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/LogitechHeadsetProtocolTests.cs index 40bbc35..376847a 100644 --- a/tests/DeviceBatteryInfo.Tests/LogitechHeadsetProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LogitechHeadsetProtocolTests.cs @@ -77,10 +77,10 @@ public void A_charge_rises_turns_after_its_peak_and_never_reads_lower() charge.Update(4097), charge.Update(4199), charge.Update(4275), - charge.Update(4273), // noise at the peak, not the turn yet + charge.Update(4273), charge.Update(4264), charge.Update(4241), - charge.Update(4245), // a falling voltage that ticks up again reads no lower + charge.Update(4245), charge.Update(4206), ];