From 7ba70780b5100d5987b5635d9f349c60ff91136c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 10 Oct 2026 00:04:01 +0200 Subject: [PATCH 1/4] feat: support the Logitech G502 Lightspeed --- AGENTS.md | 8 +- README.md | 1 + docs/linux-setup.md | 3 + packaging/linux/70-device-battery-info.rules | 3 + .../Logitech/LogitechHeadsetProtocol.cs | 29 +----- .../Sources/Logitech/LogitechHidppProtocol.cs | 32 +++++++ .../LogitechVoltageBatteryProtocol.cs | 72 +++++++++++++++ .../DeviceBatteryInfo.Tests/HardwareTests.cs | 1 + .../LogitechVoltageBatteryProtocolTests.cs | 88 +++++++++++++++++++ 9 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs create mode 100644 tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs diff --git a/AGENTS.md b/AGENTS.md index 835403e..af1fbc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -419,7 +419,13 @@ 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". Its curve is the kernel's 0x1001 table at Solaar's sample points, + again an estimate (G Hub uses its own per-device table). The `C539` receiver is shared by other + `0x1001` mice (G Pro Wireless, G703, G903), which would show under the G502 name. 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..08aa83b 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ 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. | | Logitech | G Pro X Superlight 2 | Mouse | Receiver or cable | yes | yes | | | Logitech | G502 X PLUS | Mouse | Receiver or cable | yes | yes | | | 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. | diff --git a/docs/linux-setup.md b/docs/linux-setup.md index 5800a8d..9f05be8 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -39,6 +39,9 @@ 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"' \ diff --git a/packaging/linux/70-device-battery-info.rules b/packaging/linux/70-device-battery-info.rules index a11798e..bfa5024 100644 --- a/packaging/linux/70-device-battery-info.rules +++ b/packaging/linux/70-device-battery-info.rules @@ -9,6 +9,9 @@ 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" 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..eaf9439 --- /dev/null +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs @@ -0,0 +1,72 @@ +using DeviceBatteryInfo.Core; +using DeviceBatteryInfo.Sources.Hid; + +namespace DeviceBatteryInfo.Sources.Logitech; + +// Mice that report a battery voltage (0x1001) instead of a percentage. +// Read on a G502 Lightspeed, on the cable (C08D) and behind its receiver (C539). +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 curve at the points Solaar samples, highest voltage first. + private static readonly (int Millivolts, int Percent)[] Curve = + [ + (4186, 100), + (4067, 90), + (3989, 80), + (3922, 70), + (3859, 60), + (3811, 50), + (3778, 40), + (3751, 30), + (3717, 20), + (3671, 10), + (3646, 5), + (3579, 2), + (3500, 0), + ]; + + protected override ushort BatteryFeature => 0x1001; + + // getBatteryVoltage. + protected override byte BatteryFunction => 0x00; + + public override IReadOnlyList Devices { get; } = + [new("G502 Lightspeed", 0xC539, 0xC08D)]; + + 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 mouse 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. + var status = (flags & ExternalPower) == 0 + ? percent >= 100 ? BatteryStatus.Full : BatteryStatus.Discharging + : (flags & ChargeStateMask) switch + { + 0 => BatteryStatus.Charging, + 1 => BatteryStatus.Full, + 2 => BatteryStatus.Discharging, + _ => BatteryStatus.Unknown, + }; + + return new BatteryReading { Percent = percent, Status = status }; + } + + 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..6e2ad98 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(), diff --git a/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs new file mode 100644 index 0000000..2653fda --- /dev/null +++ b/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs @@ -0,0 +1,88 @@ +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)); + } + + [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(3989, 80)] + [TestCase(3900, 67)] + [TestCase(3500, 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)); + } +} From 5aeaf565a4fbc90f7bf9ccfa30f7858be989b837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 10 Oct 2026 19:17:28 +0200 Subject: [PATCH 2/4] feat: use the full kernel voltage table and add a raw voltage hardware test --- AGENTS.md | 4 +- .../LogitechVoltageBatteryProtocol.cs | 30 +++++++------- .../DeviceBatteryInfo.Tests/HardwareTests.cs | 41 +++++++++++++++++++ .../LogitechVoltageBatteryProtocolTests.cs | 4 +- 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index af1fbc9..131c1bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -423,8 +423,8 @@ Design knowledge that is not obvious from the code alone: `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". Its curve is the kernel's 0x1001 table at Solaar's sample points, - again an estimate (G Hub uses its own per-device table). The `C539` receiver is shared by other + critical level, never "full". 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. 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 diff --git a/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs index eaf9439..e911f40 100644 --- a/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs @@ -12,22 +12,24 @@ internal sealed class LogitechVoltageBatteryProtocol() : LogitechHidppProtocol(0 private const byte ExternalPower = 0x80; private const byte ChargeStateMask = 0x07; - // The Linux kernel's 0x1001 curve at the points Solaar samples, highest voltage first. + // 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 = [ - (4186, 100), - (4067, 90), - (3989, 80), - (3922, 70), - (3859, 60), - (3811, 50), - (3778, 40), - (3751, 30), - (3717, 20), - (3671, 10), - (3646, 5), - (3579, 2), - (3500, 0), + .. KernelMillivolts.Select((millivolts, i) => (millivolts, 100 - i)), ]; protected override ushort BatteryFeature => 0x1001; diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index 6e2ad98..e757155 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -307,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() @@ -411,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 index 2653fda..a8d60bd 100644 --- a/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs @@ -77,9 +77,11 @@ public void An_error_report_throws() [TestCase(4300, 100)] [TestCase(4186, 100)] + [TestCase(4133, 97)] [TestCase(3989, 80)] [TestCase(3900, 67)] - [TestCase(3500, 0)] + [TestCase(3537, 1)] + [TestCase(3536, 0)] [TestCase(3400, 0)] public void The_curve_interpolates_between_its_calibration_points(int millivolts, int percent) { From 8da3c3bfe2b8d75ba1df27d93a07238440d31f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 16 Oct 2026 17:49:19 +0200 Subject: [PATCH 3/4] feat: add the Logitech G915 to the voltage protocol --- AGENTS.md | 5 ++++- README.md | 1 + docs/linux-setup.md | 3 ++- packaging/linux/70-device-battery-info.rules | 3 ++- .../Logitech/LogitechVoltageBatteryProtocol.cs | 11 +++++++---- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 131c1bf..097b810 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -425,7 +425,10 @@ Design knowledge that is not obvious from the code alone: state (`0` charging, `1` full, `2` not charging, else error) as the kernel reads it; bit 5 is the critical level, never "full". 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. Ask the device for its feature table + `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 08aa83b..8ebedb4 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ listed here is not supported even if it shares a brand or protocol with one that | Logitech | G502 Lightspeed | Mouse | Receiver or cable | approx. | yes | Reports a voltage, so the percentage is an estimate. | | 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. | | 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 9f05be8..30d95f0 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -45,9 +45,10 @@ printf '%s\n' \ '# 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 bfa5024..43a905f 100644 --- a/packaging/linux/70-device-battery-info.rules +++ b/packaging/linux/70-device-battery-info.rules @@ -15,9 +15,10 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c08d", TAG+="ua # 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/LogitechVoltageBatteryProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs index e911f40..e1de69c 100644 --- a/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs @@ -3,8 +3,8 @@ namespace DeviceBatteryInfo.Sources.Logitech; -// Mice that report a battery voltage (0x1001) instead of a percentage. -// Read on a G502 Lightspeed, on the cable (C08D) and behind its receiver (C539). +// 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; @@ -38,7 +38,10 @@ .. KernelMillivolts.Select((millivolts, i) => (millivolts, 100 - i)), protected override byte BatteryFunction => 0x00; public override IReadOnlyList Devices { get; } = - [new("G502 Lightspeed", 0xC539, 0xC08D)]; + [ + new("G502 Lightspeed", 0xC539, 0xC08D), + new("G915", 0xC547, 0xC33E) { Kind = BatterySourceKind.Keyboard }, + ]; protected override BatteryReading ParseBattery(ReadOnlySpan report) => ParseVoltage(report); @@ -49,7 +52,7 @@ internal static BatteryReading ParseVoltage(ReadOnlySpan report) var millivolts = report[4] << 8 | report[5]; if (millivolts == 0) { - throw new InvalidOperationException("The mouse reported 0 mV: it is off or asleep."); + throw new InvalidOperationException("The device reported 0 mV: it is off or asleep."); } var percent = PercentFromMillivolts(millivolts); From 5ba748a9c36bdce32221d87a789b63a5dc7c2be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 16 Oct 2026 18:36:06 +0200 Subject: [PATCH 4/4] fix: show no voltage level while a Logitech device charges --- AGENTS.md | 4 ++- README.md | 4 +-- .../LogitechVoltageBatteryProtocol.cs | 25 +++++++++++-------- .../LogitechVoltageBatteryProtocolTests.cs | 14 +++++++++++ 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 097b810..d571726 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -423,7 +423,9 @@ Design knowledge that is not obvious from the code alone: `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". Its curve is the kernel's full 100-entry 0x1001 table, again + 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` diff --git a/README.md b/README.md index 8ebedb4..442cf77 100644 --- a/README.md +++ b/README.md @@ -103,10 +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. | +| 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. | +| 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/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs index e1de69c..485eac1 100644 --- a/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechVoltageBatteryProtocol.cs @@ -59,17 +59,22 @@ internal static BatteryReading ParseVoltage(ReadOnlySpan report) var flags = report[6]; // The charge state bits only mean something while external power is connected. - var status = (flags & ExternalPower) == 0 - ? percent >= 100 ? BatteryStatus.Full : BatteryStatus.Discharging - : (flags & ChargeStateMask) switch - { - 0 => BatteryStatus.Charging, - 1 => BatteryStatus.Full, - 2 => BatteryStatus.Discharging, - _ => BatteryStatus.Unknown, - }; + if ((flags & ExternalPower) == 0) + { + return BatteryReading.FromPercent( + percent, + percent >= 100 ? BatteryStatus.Full : BatteryStatus.Discharging + ); + } - return new BatteryReading { Percent = percent, Status = status }; + // 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) => diff --git a/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs index a8d60bd..d8c1ec9 100644 --- a/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LogitechVoltageBatteryProtocolTests.cs @@ -49,6 +49,20 @@ public void On_external_power_the_charge_state_bits_give_the_status(byte flags, 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)