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
14 changes: 11 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\<n>\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
Expand Down
8 changes: 5 additions & 3 deletions src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte[]> ExchangeAsync(
Expand All @@ -41,22 +43,22 @@ CancellationToken cancellationToken
) =>
kind == HidReportKind.Feature
? transport.ExchangeAsync(
devicePath,
DevicePath,
request,
isComplete,
budget ?? ReadBudget,
cancellationToken
)
: transport.ExchangeReportsAsync(
devicePath,
DevicePath,
request,
isComplete,
budget ?? ReadBudget,
cancellationToken
);

public Task<byte[]> 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.
Expand Down
148 changes: 135 additions & 13 deletions src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using DeviceBatteryInfo.Core;
using DeviceBatteryInfo.Sources.Hid;

Expand All @@ -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.
Expand All @@ -33,32 +58,129 @@ private static readonly (int Millivolts, int Percent)[] Curve =
public override IReadOnlyList<HidDeviceInfo> Devices { get; } =
[new("G Pro X Wireless", 0x0ABA) { Kind = BatterySourceKind.Headset }];

private readonly ConcurrentDictionary<string, LogitechHeadsetCharge> _charges = new();

protected override BatteryReading ParseBattery(ReadOnlySpan<byte> report) =>
ParseVoltage(report);

internal static BatteryReading ParseVoltage(ReadOnlySpan<byte> report)
protected override BatteryReading ParseBattery(HidChannel channel, ReadOnlySpan<byte> 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<byte> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ internal abstract class LogitechHidppProtocol(int usagePage, int usage, byte dev

protected abstract BatteryReading ParseBattery(ReadOnlySpan<byte> report);

protected virtual BatteryReading ParseBattery(HidChannel channel, ReadOnlySpan<byte> report) =>
ParseBattery(report);

public sealed override async Task<BatteryReading> ReadAsync(
HidChannel channel,
HidDeviceInfo device,
Expand All @@ -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);
Expand Down Expand Up @@ -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++)
Expand Down
58 changes: 46 additions & 12 deletions tests/DeviceBatteryInfo.Tests/HardwareTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -390,22 +390,26 @@ private static async Task<ushort[]> CenturionFeaturesAsync(Func<byte, byte, byte
return features;
}

// For calibrating the 0x1001 curve against G Hub: the raw voltage behind the percentage.
// For calibrating the 0x1001 and 0x1F20 curves against G Hub: the raw voltage behind the percentage.
[Test]
public async Task Reads_the_raw_logitech_battery_voltage()
{
var probe = new LogitechVoltageProbe();
var (_, sources) = await DiscoverAsync([probe]);
Assume.That(sources, Is.Not.Empty, "No Logitech mouse with a battery voltage is connected.");

foreach (var source in sources)
var read = 0;
foreach (var probe in new IVoltageProbe[] { new LogitechVoltageProbe(), new LogitechHeadsetVoltageProbe() })
{
var reading = await source.ReadAsync(CancellationToken.None);
HardwareReport.Line(
$"{source.Id} {DateTime.Now:HH:mm:ss}",
$"{probe.Millivolts} mV flags 0x{probe.Flags:X2} = {reading.Percent}% {reading.Status}"
);
var (_, sources) = await DiscoverAsync([(HidProtocol)probe]);
foreach (var source in sources)
{
var reading = await source.ReadAsync(CancellationToken.None);
HardwareReport.Line(
$"{source.Id} {DateTime.Now:HH:mm:ss}",
$"{probe.Millivolts} mV flags 0x{probe.Flags:X2} = {reading.Percent}% {reading.Status}"
);
read++;
}
}

Assume.That(read, Is.GreaterThan(0), "No Logitech device with a battery voltage is connected.");
}

// Stands in for Razer Synapse polling the same control interface.
Expand Down Expand Up @@ -512,7 +516,37 @@ public void Emit(Serilog.Events.LogEvent logEvent) =>
);
}

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<HidDeviceInfo> Devices => _protocol.Devices;

protected override ushort BatteryFeature => 0x1F20;

protected override byte BatteryFunction => 0x00;

protected override BatteryReading ParseBattery(ReadOnlySpan<byte> 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();

Expand Down
Loading
Loading