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..097e08e 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( @@ -41,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, @@ -56,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 9cea400..330cf2b 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,39 @@ internal sealed class LogitechHeadsetProtocol() private const byte Charging = 0x03; - // Discharge curve from HeadsetControl's G Pro calibration, highest voltage first. + internal const int OffMillivolts = 3300; + + // Sampled from G Hub's power model for the 0ABA (pro_x_wireless_headset_battery.xml). 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 +58,129 @@ private static readonly (int Millivolts, int Percent)[] Curve = public override IReadOnlyList Devices { get; } = [new("G Pro X Wireless", 0x0ABA) { Kind = BatterySourceKind.Headset }]; + 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())); + } + + 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); } + +// 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 +{ + 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, not the 0 below a curve's end. + (LogitechHeadsetProtocol.OffMillivolts, 100), + ]; + + 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) + { + // 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); + + 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..a751296 100644 --- a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs @@ -31,6 +31,9 @@ internal abstract class LogitechHidppProtocol(int usagePage, int usage, byte dev protected abstract BatteryReading ParseBattery(ReadOnlySpan report); + protected virtual BatteryReading ParseBattery(HidChannel channel, ReadOnlySpan report) => + ParseBattery(report); + public sealed override async Task ReadAsync( HidChannel channel, HidDeviceInfo device, @@ -50,7 +53,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 +111,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..376847a 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), + charge.Update(4264), + charge.Update(4241), + charge.Update(4245), + 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));