diff --git a/AGENTS.md b/AGENTS.md index 835403e..d571726 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 2c52f03..442cf77 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/docs/linux-setup.md b/docs/linux-setup.md index 5800a8d..30d95f0 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -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' \ diff --git a/packaging/linux/70-device-battery-info.rules b/packaging/linux/70-device-battery-info.rules index a11798e..43a905f 100644 --- a/packaging/linux/70-device-battery-info.rules +++ b/packaging/linux/70-device-battery-info.rules @@ -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 diff --git a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs index 58748a7..9cea400 100644 --- a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHeadsetProtocol.cs @@ -59,31 +59,6 @@ internal static BatteryReading ParseVoltage(ReadOnlySpan 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); } diff --git a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs index 712fd40..268bdea 100644 --- a/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechHidppProtocol.cs @@ -98,4 +98,36 @@ protected static void ThrowOnError(ReadOnlySpan 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; + } } diff --git a/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs new file mode 100644 index 0000000..485eac1 --- /dev/null +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs @@ -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 Devices { get; } = + [ + new("G502 Lightspeed", 0xC539, 0xC08D), + new("G915", 0xC547, 0xC33E) { Kind = BatterySourceKind.Keyboard }, + ]; + + protected override BatteryReading ParseBattery(ReadOnlySpan report) => ParseVoltage(report); + + internal static BatteryReading ParseVoltage(ReadOnlySpan 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); +} diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index 9094412..e757155 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -26,6 +26,7 @@ public sealed class HardwareTests new RazerProtocol(), new LogitechProtocol(), new LogitechHeadsetProtocol(), + new LogitechVoltageBatteryProtocol(), new CorsairProtocol(), new RapooProtocol(), new AulaProtocol(), @@ -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() @@ -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 Devices => _protocol.Devices; + + protected override ushort BatteryFeature => 0x1001; + + protected override byte BatteryFunction => 0x00; + + protected override BatteryReading ParseBattery(ReadOnlySpan 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; diff --git a/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs new file mode 100644 index 0000000..d8c1ec9 --- /dev/null +++ b/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs @@ -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() + ); + } + + [Test] + public void An_error_report_throws() + { + Assert.That( + () => LogitechVoltageBatteryProtocol.ParseVoltage(Frame("1101FF06010500")), + Throws.TypeOf() + ); + } + + [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)); + } +}