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
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,18 @@ Design knowledge that is not obvious from the code alone:
`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
`ParseVoltage` throws there instead of publishing 0%. Ask the device for its feature table
`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
state (`0` charging, `1` full, `2` not charging, else error) as the kernel reads it; bit 5 is the
critical level, never "full". On external power the voltage reads high (a G915 read 87% on the cable
while G Hub showed 82%, a G502 Lightspeed jumped to almost 100%), so charging reads with no percent,
as on Rapoo, AULA and MCHOSE, and full reads 100. Its curve is the kernel's full 100-entry 0x1001 table, again
an estimate (G Hub uses its own per-device table); `HardwareTests` prints the raw mV for calibrating. The `C539` receiver is shared by other
`0x1001` mice (G Pro Wireless, G703, G903), which would show under the G502 name. The G915 keyboard is on
the same protocol (receiver `C547`, cable `C33E`, device index 1 on both); its feature table lists `0x1001`
and no `0x1004`, and the same `C547` receiver ships with the G502 X PLUS (`0x1004`), so the battery
feature is what tells the two receivers apart. Ask the device for its feature table
(`0x0001` getCount + getFeatureId) before assuming a feature index. `Sources/Razer/RazerProtocol.cs` is the Razer report layout, command ids
and checksum. **Only the DeathAdder V3 Pro has been tested on real hardware.** The command class
(0x07, "power") and ids are plausibly shared across Razer mice, but a mouse is added to
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@ listed here is not supported even if it shares a brand or protocol with one that
| Razer | Basilisk V3 Pro | Mouse | Dongle or cable | yes | yes | |
| Razer | Viper V2 Pro | Mouse | Dongle or cable | yes | yes | |
| Razer | Viper V3 Pro SE | Mouse | Dongle or cable | yes | yes | |
| Logitech | G502 Lightspeed | Mouse | Receiver or cable | approx. | yes | Reports a voltage, so the percentage is an estimate. No level while charging. |
| Logitech | G Pro X Superlight 2 | Mouse | Receiver or cable | yes | yes | |
| Logitech | G502 X PLUS | Mouse | Receiver or cable | yes | yes | |
| Logitech | G915 | Keyboard | Receiver or cable | approx. | yes | Reports a voltage, so the percentage is an estimate. No level while charging. |
| Logitech | G Pro X Wireless | Headset | Dongle | approx. | yes | Reports a voltage, so the percentage is an estimate. It reads nothing while the headset is off. |
| Corsair | VOID PRO Wireless | Headset | Dongle | yes | yes | It reads nothing while the headset is off. |
| 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. |
Expand Down
6 changes: 5 additions & 1 deletion docs/linux-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,16 @@ printf '%s\n' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1b1c", ATTRS{idProduct}=="0a75", TAG+="uaccess"' \
'# Logitech G Pro X Wireless' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="0aba", TAG+="uaccess"' \
'# Logitech G502 Lightspeed' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c539", TAG+="uaccess"' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c08d", TAG+="uaccess"' \
'# 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"' \
'# Logitech G502 X PLUS' \
'# Logitech G502 X PLUS and G915 (one receiver id, c547, for both)' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c547", TAG+="uaccess"' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c095", TAG+="uaccess"' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c33e", TAG+="uaccess"' \
'# MCHOSE V9 Pro' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="291d", ATTRS{idProduct}=="385d", TAG+="uaccess"' \
'# Rapoo VT3 PRO' \
Expand Down
6 changes: 5 additions & 1 deletion packaging/linux/70-device-battery-info.rules
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="3554", ATTRS{idProduct}=="fa09", TAG+="ua
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1b1c", ATTRS{idProduct}=="0a75", TAG+="uaccess"
# Logitech G Pro X Wireless
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="0aba", TAG+="uaccess"
# Logitech G502 Lightspeed
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c539", TAG+="uaccess"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c08d", TAG+="uaccess"
# 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"
# Logitech G502 X PLUS
# Logitech G502 X PLUS and G915 (one receiver id, c547, for both)
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c547", TAG+="uaccess"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c095", TAG+="uaccess"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c33e", TAG+="uaccess"
# MCHOSE V9 Pro
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="291d", ATTRS{idProduct}=="385d", TAG+="uaccess"
# Rapoo VT3 PRO
Expand Down
29 changes: 2 additions & 27 deletions src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,31 +59,6 @@ internal static BatteryReading ParseVoltage(ReadOnlySpan<byte> report)
};
}

// Linear between the calibration points, which is as much as a voltage reading is worth.
internal static int PercentFromMillivolts(int millivolts)
{
if (millivolts >= Curve[0].Millivolts)
{
return 100;
}

for (var i = 1; i < Curve.Length; i++)
{
var (low, lowPercent) = Curve[i];
if (millivolts < low)
{
continue;
}

var (high, highPercent) = Curve[i - 1];
return lowPercent
+ (int)
Math.Round(
(millivolts - low) / (double)(high - low) * (highPercent - lowPercent),
MidpointRounding.AwayFromZero
);
}

return 0;
}
internal static int PercentFromMillivolts(int millivolts) =>
LogitechHidppProtocol.PercentFromMillivolts(millivolts, Curve);
}
32 changes: 32 additions & 0 deletions src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,36 @@ protected static void ThrowOnError(ReadOnlySpan<byte> report, string what)
throw new InvalidOperationException($"HID++ error 0x{report[5]:X2} {what}.");
}
}

// Linear between the calibration points (highest voltage first), which is as much as a voltage
// reading is worth.
internal static int PercentFromMillivolts(
int millivolts,
ReadOnlySpan<(int Millivolts, int Percent)> curve
)
{
if (millivolts >= curve[0].Millivolts)
{
return 100;
}

for (var i = 1; i < curve.Length; i++)
{
var (low, lowPercent) = curve[i];
if (millivolts < low)
{
continue;
}

var (high, highPercent) = curve[i - 1];
return lowPercent
+ (int)
Math.Round(
(millivolts - low) / (double)(high - low) * (highPercent - lowPercent),
MidpointRounding.AwayFromZero
);
}

return 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using DeviceBatteryInfo.Core;
using DeviceBatteryInfo.Sources.Hid;

namespace DeviceBatteryInfo.Sources.Logitech;

// Devices that report a battery voltage (0x1001) instead of a percentage.
// Read on a G502 Lightspeed (receiver C539, cable C08D); the G915 (C547, C33E) lists the same feature.
internal sealed class LogitechVoltageBatteryProtocol() : LogitechHidppProtocol(0xFF00, 0x0002, DeviceIndex)
{
internal const byte DeviceIndex = 0x01;

private const byte ExternalPower = 0x80;
private const byte ChargeStateMask = 0x07;

// The Linux kernel's 0x1001 table (hidpp20_map_battery_capacity): entry i is 100 - i percent.
private static readonly int[] KernelMillivolts =
[
4186, 4156, 4143, 4133, 4122, 4113, 4103, 4094, 4086, 4075,
4067, 4059, 4051, 4043, 4035, 4027, 4019, 4011, 4003, 3997,
3989, 3983, 3976, 3969, 3961, 3955, 3949, 3942, 3935, 3929,
3922, 3916, 3909, 3902, 3896, 3890, 3883, 3877, 3870, 3865,
3859, 3853, 3848, 3842, 3837, 3833, 3828, 3824, 3819, 3815,
3811, 3808, 3804, 3800, 3797, 3793, 3790, 3787, 3784, 3781,
3778, 3775, 3772, 3770, 3767, 3764, 3762, 3759, 3757, 3754,
3751, 3748, 3744, 3741, 3737, 3734, 3730, 3726, 3724, 3720,
3717, 3714, 3710, 3706, 3702, 3697, 3693, 3688, 3683, 3677,
3671, 3666, 3662, 3658, 3654, 3646, 3633, 3612, 3579, 3537,
];

private static readonly (int Millivolts, int Percent)[] Curve =
[
.. KernelMillivolts.Select((millivolts, i) => (millivolts, 100 - i)),
];

protected override ushort BatteryFeature => 0x1001;

// getBatteryVoltage.
protected override byte BatteryFunction => 0x00;

public override IReadOnlyList<HidDeviceInfo> Devices { get; } =
[
new("G502 Lightspeed", 0xC539, 0xC08D),
new("G915", 0xC547, 0xC33E) { Kind = BatterySourceKind.Keyboard },
];

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

internal static BatteryReading ParseVoltage(ReadOnlySpan<byte> report)
{
ThrowOnError(report, "reading the battery voltage");

var millivolts = report[4] << 8 | report[5];
if (millivolts == 0)
{
throw new InvalidOperationException("The device reported 0 mV: it is off or asleep.");
}

var percent = PercentFromMillivolts(millivolts);
var flags = report[6];

// The charge state bits only mean something while external power is connected.
if ((flags & ExternalPower) == 0)
{
return BatteryReading.FromPercent(
percent,
percent >= 100 ? BatteryStatus.Full : BatteryStatus.Discharging
);
}

// The voltage reads high while charging (87% against G Hub's 82% on a G915), so no percent then.
return (flags & ChargeStateMask) switch
{
0 => new BatteryReading { Status = BatteryStatus.Charging },
1 => BatteryReading.FromPercent(100, BatteryStatus.Full),
2 => BatteryReading.FromPercent(percent, BatteryStatus.Discharging),
_ => BatteryReading.FromPercent(percent),
};
}

internal static int PercentFromMillivolts(int millivolts) =>
LogitechHidppProtocol.PercentFromMillivolts(millivolts, Curve);
}
42 changes: 42 additions & 0 deletions tests/DeviceBatteryInfo.Tests/HardwareTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public sealed class HardwareTests
new RazerProtocol(),
new LogitechProtocol(),
new LogitechHeadsetProtocol(),
new LogitechVoltageBatteryProtocol(),
new CorsairProtocol(),
new RapooProtocol(),
new AulaProtocol(),
Expand Down Expand Up @@ -306,6 +307,24 @@ params byte[] parameters
: reply;
}

// For calibrating the 0x1001 curve 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 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}"
);
}
}

// 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 Expand Up @@ -410,6 +429,29 @@ public void Emit(Serilog.Events.LogEvent logEvent) =>
);
}

private sealed class LogitechVoltageProbe() : LogitechHidppProtocol(0xFF00, 0x0002, 0x01)
{
private readonly LogitechVoltageBatteryProtocol _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 => 0x1001;

protected override byte BatteryFunction => 0x00;

protected override BatteryReading ParseBattery(ReadOnlySpan<byte> report)
{
var reading = LogitechVoltageBatteryProtocol.ParseVoltage(report);
Millivolts = report[4] << 8 | report[5];
Flags = report[6];
return reading;
}
}

private sealed class RetryCounter : Serilog.Core.ILogEventSink
{
private int _count;
Expand Down
104 changes: 104 additions & 0 deletions tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using DeviceBatteryInfo.Core;
using DeviceBatteryInfo.Sources.Logitech;
using NUnit.Framework;

namespace DeviceBatteryInfo.Tests;

[TestFixture]
public sealed class LogitechVoltageBatteryProtocolTests
{
// Frames recorded from a G502 Lightspeed behind receiver C539.
private static byte[] Frame(string hex) => Convert.FromHexString(hex.PadRight(40, '0'));

private static readonly byte[] FeatureAnswer = Frame("110100010600020000000000");
private static readonly byte[] VoltageAnswer = Frame("110106011049000000000000");

private static BatteryStatus StatusWithFlags(byte flags) =>
LogitechVoltageBatteryProtocol.ParseVoltage(Frame($"110106011049{flags:X2}")).Status;

[Test]
public void The_battery_feature_lookup_asks_for_feature_1001()
{
Assert.That(
LogitechHidppProtocol.BuildFeatureRequest(LogitechVoltageBatteryProtocol.DeviceIndex, 0x1001),
Is.EqualTo(new byte[] { 0x11, 0x01, 0x00, 0x01, 0x10, 0x01 })
);
}

[Test]
public void The_recorded_answers_give_feature_index_six_and_a_voltage()
{
var reading = LogitechVoltageBatteryProtocol.ParseVoltage(VoltageAnswer);

using (Assert.EnterMultipleScope())
{
Assert.That(LogitechHidppProtocol.ParseFeatureIndex(FeatureAnswer), Is.EqualTo(6));
Assert.That(reading.Percent, Is.EqualTo(99));
Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Discharging));
}
}

[TestCase(0x80, BatteryStatus.Charging)]
[TestCase(0x81, BatteryStatus.Full)]
[TestCase(0x82, BatteryStatus.Discharging)]
[TestCase(0x83, BatteryStatus.Unknown)]
[TestCase(0x87, BatteryStatus.Unknown)]
[TestCase(0x88, BatteryStatus.Charging)]
public void On_external_power_the_charge_state_bits_give_the_status(byte flags, BatteryStatus status)
{
Assert.That(StatusWithFlags(flags), Is.EqualTo(status));
}

[Test]
public void Charging_has_no_percent_and_full_reads_100()
{
var charging = LogitechVoltageBatteryProtocol.ParseVoltage(Frame("110106010FCA80"));
var full = LogitechVoltageBatteryProtocol.ParseVoltage(Frame("110106011049" + "81"));

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

[TestCase(0x01)]
[TestCase(0x20)]
public void Without_external_power_the_mouse_is_discharging(byte flags)
{
// 0x20 is the critical level bit, not a full battery.
Assert.That(StatusWithFlags(flags), Is.EqualTo(BatteryStatus.Discharging));
}

[Test]
public void Zero_millivolts_throws()
{
Assert.That(
() => LogitechVoltageBatteryProtocol.ParseVoltage(Frame("110106010000")),
Throws.TypeOf<InvalidOperationException>()
);
}

[Test]
public void An_error_report_throws()
{
Assert.That(
() => LogitechVoltageBatteryProtocol.ParseVoltage(Frame("1101FF06010500")),
Throws.TypeOf<InvalidOperationException>()
);
}

[TestCase(4300, 100)]
[TestCase(4186, 100)]
[TestCase(4133, 97)]
[TestCase(3989, 80)]
[TestCase(3900, 67)]
[TestCase(3537, 1)]
[TestCase(3536, 0)]
[TestCase(3400, 0)]
public void The_curve_interpolates_between_its_calibration_points(int millivolts, int percent)
{
Assert.That(LogitechVoltageBatteryProtocol.PercentFromMillivolts(millivolts), Is.EqualTo(percent));
}
}
Loading