From cb4dfb1e9621618da2c851293c2f5cd33a04f6e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 15 Oct 2026 15:13:18 +0200 Subject: [PATCH 1/3] feat: add the Logitech G502 X PLUS and a HID++ feature listing hardware test --- README.md | 1 + docs/linux-setup.md | 3 + packaging/linux/70-device-battery-info.rules | 3 + .../Sources/Logitech/LogitechProtocol.cs | 5 +- .../DeviceBatteryInfo.Tests/HardwareTests.cs | 97 +++++++++++++++++++ 5 files changed, 108 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1199c9c..2c52f03 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ listed here is not supported even if it shares a brand or protocol with one that | Razer | Viper V2 Pro | Mouse | Dongle or cable | yes | yes | | | Razer | Viper V3 Pro SE | Mouse | Dongle or cable | yes | yes | | | 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. | | 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 ff54781..5800a8d 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -42,6 +42,9 @@ 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' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c547", TAG+="uaccess"' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c095", 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 5838fd2..a11798e 100644 --- a/packaging/linux/70-device-battery-info.rules +++ b/packaging/linux/70-device-battery-info.rules @@ -12,6 +12,9 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="0aba", 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 +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c547", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c095", 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/LogitechProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechProtocol.cs index 82c836b..7df8c44 100644 --- a/src/DeviceBatteryInfo/Sources/Logitech/LogitechProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechProtocol.cs @@ -15,7 +15,10 @@ internal sealed class LogitechProtocol() : LogitechHidppProtocol(0xFF00, 0x0002, protected override byte BatteryFunction => 0x01; public override IReadOnlyList Devices { get; } = - [new("G Pro X Superlight 2", 0xC54D, 0xC09B)]; + [ + new("G Pro X Superlight 2", 0xC54D, 0xC09B), + new("G502 X PLUS", 0xC547, 0xC095), + ]; protected override BatteryReading ParseBattery(ReadOnlySpan report) => ParseStatus(report); diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index 8a03c81..aecc0e8 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -201,6 +201,103 @@ public async Task Reads_the_raw_mchose_battery_report() } } + // Shows which battery feature (0x1000, 0x1001, 0x1004) a Logitech device has before it gets a protocol. + [Test] + public async Task Lists_the_hidpp_features_of_every_logitech_device() + { + var transport = new HidSharpTransport(Serilog.Core.Logger.None); + const int vendorId = 0x046D; + IEnumerable interfaces = OperatingSystem.IsMacOS() + ? MacHid.Enumerate(vendorId).Select(HidSharpTransport.Describe) + : DeviceList.Local.GetHidDevices(vendorId).Select(d => HidSharpTransport.Describe(d, withUsage: true)); + var candidates = interfaces + .Select(c => c.ProductId) + .Distinct() + .Order() + .SelectMany(productId => transport.FindCandidates(vendorId, productId, interfaceNumber: null, 0)) + .Where(c => !c.CouldNotOpen && c.OutputReportLength >= 20 && c.HasUsage(0xFF00, 0x0002)) + .ToArray(); + Assume.That(candidates, Is.Not.Empty, "No Logitech HID++ interface present."); + + foreach (var candidate in candidates) + { + var subject = $"{candidate.ProductName} ({candidate.ProductId:X4}) interface {candidate.InterfaceNumber}"; + var channel = new HidChannel(transport, candidate.Path, candidate.ProductId, HidReportKind.InputOutput); + foreach (var deviceIndex in new byte[] { 0x01, 0xFF }) + { + try + { + HardwareReport.Line( + $"{subject} index {deviceIndex:X2}", + await DescribeHidppDeviceAsync(channel, deviceIndex) + ); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + HardwareReport.Line($"{subject} index {deviceIndex:X2}", "no answer: " + exception.Message); + } + } + } + } + + private static async Task DescribeHidppDeviceAsync(HidChannel channel, byte deviceIndex) + { + var featureSet = await FeatureIndexAsync(channel, deviceIndex, 0x0001); + var count = (await CallHidppAsync(channel, deviceIndex, featureSet, 0x00))[4]; + var features = new List(); + for (var i = 1; i <= count; i++) + { + var reply = await CallHidppAsync(channel, deviceIndex, featureSet, 0x01, (byte)i); + features.Add($"{reply[4] << 8 | reply[5]:X4}"); + } + + string name; + try + { + var nameFeature = await FeatureIndexAsync(channel, deviceIndex, 0x0005); + var length = (await CallHidppAsync(channel, deviceIndex, nameFeature, 0x00))[4]; + var text = new System.Text.StringBuilder(); + while (text.Length < length) + { + var reply = await CallHidppAsync(channel, deviceIndex, nameFeature, 0x01, (byte)text.Length); + var chunk = reply.AsSpan(4, Math.Min(length - text.Length, reply.Length - 4)); + text.Append(System.Text.Encoding.ASCII.GetString(chunk)); + } + + name = text.ToString(); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + name = "(no name)"; + } + + return $"{name}: features {string.Join(" ", features)}"; + } + + private static async Task FeatureIndexAsync(HidChannel channel, byte deviceIndex, ushort featureId) => + LogitechHidppProtocol.ParseFeatureIndex( + await CallHidppAsync(channel, deviceIndex, 0x00, 0x00, (byte)(featureId >> 8), (byte)featureId) + ); + + private static async Task CallHidppAsync( + HidChannel channel, + byte deviceIndex, + byte featureIndex, + byte functionId, + params byte[] parameters + ) + { + var function = LogitechHidppProtocol.Function(functionId); + var reply = await channel.ExchangeAsync( + [0x11, deviceIndex, featureIndex, function, .. parameters], + r => LogitechHidppProtocol.IsReplyTo(r, deviceIndex, featureIndex, function), + CancellationToken.None + ); + return reply[2] == 0xFF + ? throw new InvalidOperationException($"HID++ error 0x{reply[5]:X2}.") + : reply; + } + // Stands in for Razer Synapse polling the same control interface. [Test] public async Task Reads_while_a_foreign_poller_hammers_the_same_interface() From b5f105b51347bc419f1d1bfb1a1ede01dcdba2bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 15 Oct 2026 23:15:35 +0200 Subject: [PATCH 2/3] fix: never fall back to a HID candidate that answered as a different device --- AGENTS.md | 6 ++++- .../Sources/Hid/HidFamily.cs | 11 +++++++-- .../DeviceBatteryInfo.Tests/HardwareTests.cs | 23 ++++++++++++++++++- .../DeviceBatteryInfo.Tests/HidFamilyTests.cs | 17 ++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d009d0..835403e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -453,7 +453,11 @@ Design knowledge that is not obvious from the code alone: `0x08`, `0x88`, `0x00`, `0x1E`, `0x9F` and `0x80` were all measured against a wired Basilisk V3 Pro and every one of them answered `0x04` on the idle dongle and succeeded on the cable. `FindCandidates` orders by interface ascending; `HidFamily` probes each once with the shorter `HidChannel.ProbeBudget` (for both report kinds), so a silent interface - cannot stall the poll, and caches the answering path. A wireless mouse has a dongle product id and a cable product id, so a + cannot stall the poll, and caches the answering path. When nothing answers the probe, it keeps the first + candidate that failed for another reason than `NotSupportedException`, which a protocol throws when the + device answered but is a different one: two Logitech receivers share `C547`, one with a G502 X PLUS + (`0x1004`) and one with a G915 (`0x1001` only), and a mouse probe over the receiver can outlast the + 400 ms, so falling back to the first candidate bound the mouse to the keyboard. A wireless mouse has a dongle product id and a cable product id, so a `HidDeviceInfo` lists both (`FindCandidates` runs once per id) and `HidChannel.ProductId` says which is in use. When a read fails `HidFamily` forgets the remembered interface, so the next poll probes again and picks up a dongle-to-cable switch; without that, an unplugged-from-radio mouse whose dongle is diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs b/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs index e518116..bd08715 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs @@ -189,6 +189,7 @@ CancellationToken cancellationToken return known; } + HidCandidate? fallback = null; foreach (var candidate in candidates) { try @@ -211,13 +212,19 @@ CancellationToken cancellationToken ); return candidate; } + // A different device behind the same product id (two Logitech receivers) is never the fallback. + catch (NotSupportedException exception) + { + _logger.Debug(exception, "HID candidate {Path} is a different device.", candidate.Path); + } catch (Exception exception) when (exception is not OperationCanceledException) { _logger.Debug(exception, "HID candidate {Path} did not answer.", candidate.Path); + fallback ??= candidate; } } - // Nothing answered; keep the first path so the device shows as stale instead of disappearing. - return candidates[0]; + // Nothing answered; keep a path so the device shows as stale instead of disappearing. + return fallback ?? candidates[0]; } } diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index aecc0e8..10d227e 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -123,7 +123,15 @@ public void Lists_hid_interfaces_of_every_supported_brand() [Test] public async Task Reads_every_supported_hid_device_through_the_plugin() { - var (family, sources) = await DiscoverAsync(Protocols); + var probes = new ProbeLog(); + var (family, sources) = await DiscoverAsync( + Protocols, + new Serilog.LoggerConfiguration().MinimumLevel.Debug().WriteTo.Sink(probes).CreateLogger() + ); + foreach (var line in probes.Lines) + { + HardwareReport.Line("probe", line); + } var read = 0; foreach (var model in family.Models) @@ -389,6 +397,19 @@ private static void Hammer(HidCandidate candidate, CancellationToken cancellatio } } + private sealed class ProbeLog : Serilog.Core.ILogEventSink + { + private readonly System.Collections.Concurrent.ConcurrentQueue _lines = new(); + + public IEnumerable Lines => _lines; + + public void Emit(Serilog.Events.LogEvent logEvent) => + _lines.Enqueue( + logEvent.RenderMessage() + + (logEvent.Exception is { } exception ? $" ({exception.Message})" : "") + ); + } + private sealed class RetryCounter : Serilog.Core.ILogEventSink { private int _count; diff --git a/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs b/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs index 35925cb..c629e6c 100644 --- a/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs @@ -51,6 +51,12 @@ CancellationToken cancellationToken throw new InvalidOperationException("HidD_GetFeature failed."); } + // mi_01 answers, but as a different device than the protocol is looking for. + if (devicePath.Contains("mi_01", StringComparison.Ordinal)) + { + throw new NotSupportedException("The device does not have that battery feature."); + } + // A minimal completed frame with the value 180 at the response index. var response = new byte[11]; response[10] = 180; @@ -116,6 +122,17 @@ public async Task Probes_past_the_boot_interface_to_the_one_that_answers() } } + [Test] + public async Task A_different_device_is_never_the_fallback_when_nothing_answers() + { + var transport = new FakeTransport(Candidate(1), Candidate(0)); + var sources = await DiscoverAsync(transport, MouseSlot("mouse", "Mouse")); + transport.Queried.Clear(); + + Assert.ThrowsAsync(async () => await sources[0].ReadAsync(CancellationToken.None)); + Assert.That(transport.Queried, Is.EqualTo(new[] { Candidate(0).Path })); + } + [Test] public async Task No_matching_device_yields_no_source() { From b0be9e3f79c338a5d3ce6c6520891d592b89ee62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 16 Oct 2026 17:54:24 +0200 Subject: [PATCH 3/3] fix: render the hardware probe log with the invariant culture --- tests/DeviceBatteryInfo.Tests/HardwareTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index 10d227e..9094412 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -405,7 +405,7 @@ private sealed class ProbeLog : Serilog.Core.ILogEventSink public void Emit(Serilog.Events.LogEvent logEvent) => _lines.Enqueue( - logEvent.RenderMessage() + logEvent.RenderMessage(System.Globalization.CultureInfo.InvariantCulture) + (logEvent.Exception is { } exception ? $" ({exception.Message})" : "") ); }