From c2c524d97573a27ae0184334e123eaca62c9b644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 16 Oct 2026 18:52:25 +0200 Subject: [PATCH 1/2] feat: support the Logitech G Pro X 2 Lightspeed --- AGENTS.md | 14 ++ README.md | 1 + docs/linux-setup.md | 2 + packaging/linux/70-device-battery-info.rules | 2 + .../Logitech/LogitechCenturionProtocol.cs | 175 +++++++++++++++++ .../DeviceBatteryInfo.Tests/HardwareTests.cs | 83 ++++++++ .../LogitechCenturionProtocolTests.cs | 184 ++++++++++++++++++ 7 files changed, 461 insertions(+) create mode 100644 src/DeviceBatteryInfo/Sources/Logitech/LogitechCenturionProtocol.cs create mode 100644 tests/DeviceBatteryInfo.Tests/LogitechCenturionProtocolTests.cs diff --git a/AGENTS.md b/AGENTS.md index d571726..480f021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -481,6 +481,20 @@ Design knowledge that is not obvious from the code alone: config flow never asks for a USB id or an interface. There is no in-UI "custom device" path by design (a raw USB id alone cannot drive the Razer HID protocol); an unlisted device is a model in a family. +- **The Logitech G Pro X 2 Lightspeed (`0AF7`) is not HID++ but Logitech's "Centurion" framing**, mapped + from Solaar and HeadsetControl as HaloBattery documents them, not yet read through the plugin. + `Sources/Logitech/LogitechCenturionProtocol.cs` talks on `FFA0:0001` (64-byte reports, interface 3): + a frame is `51 `, `len` = payload + 1. The receiver answers its own features + directly (` `, an error is `FF `) and reaches the + headset through its bridge feature `0x0003`: ` 11 00 ...`, + which the receiver first acknowledges (` 11`) before the headset answers (` 10 + 00 `, index `FF` = rejected). A read is three exchanges: Root getFeature for + the bridge, Root getFeature for the headset's battery `0x0104` through the bridge, then its getStatus: + byte 0 the level, byte 2 the state (`0` discharging, `1` charging, `2` charging over USB, `3` + complete). A headset that is off leaves only the acknowledgement, so the read times out. + HaloBattery and Solaar find the indices by listing FeatureSet instead of asking Root for each id; + `HardwareTests.Lists_the_centurion_features_of_the_logitech_headset` does that listing to show where a + read stops. HeadsetControl's fixed request for firmware without `0x0104` is not implemented. - **The Corsair VOID headsets answer one output report.** `Sources/Corsair/CorsairProtocol.cs` covers the VOID PRO Wireless headset: output report `C9 64` on the vendor interface `FFC5:0001` (`HidReportKind.InputOutput`), answered by input report `0x64` with the level in byte 2 diff --git a/README.md b/README.md index 442cf77..ae7bbea 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ listed here is not supported even if it shares a brand or protocol with one that | 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. | +| Logitech | G Pro X 2 Lightspeed | Headset | Dongle | yes | yes | 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. | | AULA | F75 | Keyboard | 2.4G receiver | yes | yes | No level while the cable is in, only that it charges. Not read in wired mode. | diff --git a/docs/linux-setup.md b/docs/linux-setup.md index 30d95f0..679c525 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -39,6 +39,8 @@ 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 G Pro X 2 Lightspeed' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="0af7", 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"' \ diff --git a/packaging/linux/70-device-battery-info.rules b/packaging/linux/70-device-battery-info.rules index 43a905f..6fe6471 100644 --- a/packaging/linux/70-device-battery-info.rules +++ b/packaging/linux/70-device-battery-info.rules @@ -9,6 +9,8 @@ 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 G Pro X 2 Lightspeed +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="0af7", 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" diff --git a/src/DeviceBatteryInfo/Sources/Logitech/LogitechCenturionProtocol.cs b/src/DeviceBatteryInfo/Sources/Logitech/LogitechCenturionProtocol.cs new file mode 100644 index 0000000..12af2c5 --- /dev/null +++ b/src/DeviceBatteryInfo/Sources/Logitech/LogitechCenturionProtocol.cs @@ -0,0 +1,175 @@ +using DeviceBatteryInfo.Core; +using DeviceBatteryInfo.Sources.Hid; + +namespace DeviceBatteryInfo.Sources.Logitech; + +// Logitech's Centurion framing, not HID++; layout from Solaar and HeadsetControl via HaloBattery (MIT). +internal sealed class LogitechCenturionProtocol() + : HidProtocol( + "Logitech", + vendorId: 0x046D, + reportLength: 64, + HidReportKind.InputOutput, + usagePage: 0xFFA0, + usage: 0x0001 + ) +{ + private const byte ReportId = 0x51; + private const byte SoftwareId = 0x01; + private const byte RootIndex = 0x00; + private const byte ErrorIndex = 0xFF; + + private const byte FirstFunction = 0x00; + + private const byte BridgeSend = 0x10; + + internal const ushort BridgeFeature = 0x0003; + internal const ushort BatteryFeature = 0x0104; + + private const byte Discharging = 0; + private const byte Charging = 1; + private const byte ChargingOverUsb = 2; + private const byte ChargeComplete = 3; + + public override IReadOnlyList Devices { get; } = + [new("G Pro X 2 Lightspeed", 0x0AF7) { Kind = BatterySourceKind.Headset }]; + + public override async Task ReadAsync( + HidChannel channel, + HidDeviceInfo device, + CancellationToken cancellationToken + ) + { + var bridge = ParseFeatureIndex( + await DirectAsync(channel, RootIndex, FirstFunction, Id(BridgeFeature), cancellationToken) + ); + var battery = ParseFeatureIndex( + await BridgeAsync(channel, bridge, RootIndex, FirstFunction, Id(BatteryFeature), cancellationToken) + ); + return ParseBattery(await BridgeAsync(channel, bridge, battery, FirstFunction, [], cancellationToken)); + } + + internal static async Task DirectAsync( + HidChannel channel, + byte featureIndex, + byte function, + byte[] parameters, + CancellationToken cancellationToken + ) + { + var reply = Payload( + await channel.ExchangeAsync( + BuildDirectRequest(featureIndex, function, parameters), + r => IsDirectReply(r, featureIndex, function), + cancellationToken + ) + ); + return reply[0] == ErrorIndex + ? throw new InvalidOperationException($"Centurion error 0x{reply[3]:X2} on receiver feature {featureIndex}.") + : reply[2..]; + } + + internal static async Task BridgeAsync( + HidChannel channel, + byte bridgeIndex, + byte featureIndex, + byte function, + byte[] parameters, + CancellationToken cancellationToken + ) + { + var reply = Payload( + await channel.ExchangeAsync( + BuildBridgeRequest(bridgeIndex, featureIndex, function, parameters), + r => IsBridgeReply(r, bridgeIndex, featureIndex, function), + cancellationToken + ) + ); + return reply[5] == ErrorIndex + ? throw new InvalidOperationException($"The headset rejected feature {featureIndex}.") + : reply[7..]; + } + + internal static byte[] Id(ushort featureId) => [(byte)(featureId >> 8), (byte)featureId]; + + private static byte WithSoftwareId(byte function) => (byte)((function & 0xF0) | SoftwareId); + + private static byte[] Frame(params byte[] payload) => + [ReportId, (byte)(payload.Length + 1), 0x00, .. payload]; + + internal static byte[] BuildDirectRequest(byte featureIndex, byte function, params byte[] parameters) => + Frame([featureIndex, WithSoftwareId(function), .. parameters]); + + internal static byte[] BuildBridgeRequest( + byte bridgeIndex, + byte featureIndex, + byte function, + params byte[] parameters + ) + { + byte[] message = [0x00, featureIndex, WithSoftwareId(function), .. parameters]; + return Frame( + [ + bridgeIndex, + WithSoftwareId(BridgeSend), + (byte)(message.Length >> 8 & 0x0F), + (byte)message.Length, + .. message, + ] + ); + } + + internal static byte[] Payload(byte[] report) => + report.Length >= 4 && report[0] == ReportId && report[1] > 1 && report[1] + 2 <= report.Length + ? report[3..(2 + report[1])] + : []; + + internal static bool IsDirectReply(byte[] report, byte featureIndex, byte function) + { + var payload = Payload(report); + var echoed = WithSoftwareId(function); + return payload.Length >= 2 + && ( + (payload[0] == featureIndex && payload[1] == echoed) + || (payload.Length >= 4 && payload[0] == ErrorIndex && payload[1] == featureIndex && payload[2] == echoed) + ); + } + + // The receiver acknowledges the send first; only the headset's answer counts. + internal static bool IsBridgeReply(byte[] report, byte bridgeIndex, byte featureIndex, byte function) + { + var payload = Payload(report); + return payload.Length >= 7 + && payload[0] == bridgeIndex + && payload[1] == BridgeSend + && payload[4] == 0x00 + && ( + (payload[5] == featureIndex && payload[6] == WithSoftwareId(function)) + || (payload[5] == ErrorIndex && payload[6] == featureIndex) + ); + } + + internal static byte ParseFeatureIndex(ReadOnlySpan data) => + data.Length > 0 && data[0] != 0 + ? data[0] + : throw new NotSupportedException("The device does not have that Centurion feature."); + + internal static BatteryReading ParseBattery(ReadOnlySpan data) + { + if (data.Length == 0 || data[0] > 100) + { + throw new InvalidOperationException( + $"The headset reported level {(data.Length == 0 ? "nothing" : data[0].ToString(System.Globalization.CultureInfo.InvariantCulture))}." + ); + } + + var level = data[0]; + return (data.Length >= 3 ? data[2] : Discharging) switch + { + Discharging => BatteryReading.FromPercent(level, BatteryStatus.Discharging), + Charging or ChargingOverUsb => BatteryReading.FromPercent(level, BatteryStatus.Charging), + ChargeComplete => BatteryReading.FromPercent(level, BatteryStatus.Full), + _ => BatteryReading.FromPercent(level), + }; + } +} diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index e757155..8c04f3b 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 LogitechCenturionProtocol(), new LogitechVoltageBatteryProtocol(), new CorsairProtocol(), new RapooProtocol(), @@ -307,6 +308,88 @@ params byte[] parameters : reply; } + [Test] + public async Task Lists_the_centurion_features_of_the_logitech_headset() + { + var protocol = new LogitechCenturionProtocol(); + var transport = new HidSharpTransport(Serilog.Core.Logger.None); + var candidates = protocol + .Devices.SelectMany(d => d.ProductIds) + .SelectMany(productId => transport.FindCandidates(protocol.VendorId, productId, interfaceNumber: null, 0)) + .Where(c => + !c.CouldNotOpen + && c.OutputReportLength >= protocol.ReportLength + && c.HasUsage(protocol.UsagePage, protocol.Usage) + ) + .ToArray(); + Assume.That(candidates, Is.Not.Empty, "No Logitech Centurion receiver is connected."); + + foreach (var candidate in candidates) + { + var subject = $"{candidate.ProductName} ({candidate.ProductId:X4})"; + var channel = new HidChannel(transport, candidate.Path, candidate.ProductId, protocol.ReportKind); + var step = "receiver features"; + try + { + var receiver = await CenturionFeaturesAsync( + (index, function, parameters) => + LogitechCenturionProtocol.DirectAsync(channel, index, function, parameters, CancellationToken.None) + ); + HardwareReport.Line(subject, "receiver: " + string.Join(" ", receiver.Select(f => $"{f:X4}"))); + + step = "headset features"; + var bridge = (byte)Array.IndexOf(receiver, LogitechCenturionProtocol.BridgeFeature); + var headset = await CenturionFeaturesAsync( + (index, function, parameters) => + LogitechCenturionProtocol.BridgeAsync( + channel, + bridge, + index, + function, + parameters, + CancellationToken.None + ) + ); + HardwareReport.Line(subject, $"headset via bridge {bridge}: " + string.Join(" ", headset.Select(f => $"{f:X4}"))); + + step = "battery"; + var battery = (byte)Array.IndexOf(headset, LogitechCenturionProtocol.BatteryFeature); + var data = await LogitechCenturionProtocol.BridgeAsync( + channel, + bridge, + battery, + 0x00, + [], + CancellationToken.None + ); + var reading = LogitechCenturionProtocol.ParseBattery(data); + HardwareReport.Line( + $"{subject} {DateTime.Now:HH:mm:ss}", + $"battery {string.Join(" ", data.Take(8).Select(b => b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture)))}" + + $" = {reading.Percent}% {reading.Status}" + ); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + HardwareReport.Line(subject, $"stopped at {step}: {exception.Message}"); + } + } + } + + private static async Task CenturionFeaturesAsync(Func> call) + { + var featureSet = LogitechCenturionProtocol.ParseFeatureIndex(await call(0x00, 0x00, [0x00, 0x01])); + var count = (await call(featureSet, 0x00, []))[0]; + var features = new ushort[count]; + for (var n = 0; n < count; n++) + { + var data = await call(featureSet, 0x10, [(byte)n]); + features[n] = (ushort)(data[1] << 8 | data[2]); + } + + return features; + } + // For calibrating the 0x1001 curve against G Hub: the raw voltage behind the percentage. [Test] public async Task Reads_the_raw_logitech_battery_voltage() diff --git a/tests/DeviceBatteryInfo.Tests/LogitechCenturionProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/LogitechCenturionProtocolTests.cs new file mode 100644 index 0000000..9c4e3f6 --- /dev/null +++ b/tests/DeviceBatteryInfo.Tests/LogitechCenturionProtocolTests.cs @@ -0,0 +1,184 @@ +using DeviceBatteryInfo.Core; +using DeviceBatteryInfo.Sources.Hid; +using DeviceBatteryInfo.Sources.Logitech; +using NUnit.Framework; + +namespace DeviceBatteryInfo.Tests; + +[TestFixture] +public sealed class LogitechCenturionProtocolTests +{ + private sealed class FakeReceiver(byte level, byte state, bool headsetOn = true) : IHidTransport + { + private static readonly ushort[] Receiver = [0x0000, 0x0001, 0x0100, 0x0003, 0x010A]; + private static readonly ushort[] Headset = [0x0000, 0x0001, 0x0101, 0x0100, 0x0104, 0x010A]; + + public List Writes { get; } = []; + + public IReadOnlyList FindCandidates( + int vendorId, + int productId, + int? interfaceNumber, + int minFeatureReportLength + ) => []; + + public IReadOnlyList ListFeatureReportDevices() => []; + + public Task ExchangeAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + CancellationToken cancellationToken + ) => throw new NotSupportedException(); + + public Task ExchangeReportsAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + CancellationToken cancellationToken + ) + { + Writes.Add(request); + var reply = Replies(LogitechCenturionProtocol.Payload(request)).FirstOrDefault(isComplete); + return reply is null + ? throw new TimeoutException("Not answered.") + : Task.FromResult(reply); + } + + private IEnumerable Replies(byte[] payload) + { + yield return Frame(0x07, 0x01, 0x05); + + if (payload[0] == 3 && payload[1] == 0x11) + { + var message = payload[4..]; + yield return Frame(3, 0x11); + if (!headsetOn) + { + yield break; + } + + var data = Answer(Headset, message[1], (byte)(message[2] & 0xF0), message[3..]); + yield return data is null + ? Frame(3, 0x10, 0, 4, 0x00, 0xFF, message[1]) + : Frame([3, 0x10, 0, (byte)(3 + data.Length), 0x00, message[1], message[2], .. data]); + } + else + { + var data = Answer(Receiver, payload[0], (byte)(payload[1] & 0xF0), payload[2..]); + yield return data is null + ? Frame(0xFF, payload[0], payload[1], 0x05) + : Frame([payload[0], payload[1], .. data]); + } + } + + private byte[]? Answer(ushort[] table, byte index, byte function, byte[] parameters) + { + if (index == 0 && function == 0x00) + { + var wanted = (ushort)(parameters[0] << 8 | parameters[1]); + return [(byte)Math.Max(Array.IndexOf(table, wanted), 0)]; + } + + return index < table.Length && table[index] == LogitechCenturionProtocol.BatteryFeature && function == 0x00 + ? [level, 0, state] + : null; + } + + private static byte[] Frame(params byte[] payload) + { + var report = new byte[64]; + report[0] = 0x51; + report[1] = (byte)(payload.Length + 1); + payload.CopyTo(report, 3); + return report; + } + } + + private static Task ReadAsync(FakeReceiver receiver) => + new LogitechCenturionProtocol().ReadAsync( + new HidChannel(receiver, "ffa0", 0x0AF7, HidReportKind.InputOutput), + new LogitechCenturionProtocol().Devices[0], + CancellationToken.None + ); + + [Test] + public void The_requests_are_framed_as_solaar_and_headsetcontrol_frame_them() + { + using (Assert.EnterMultipleScope()) + { + Assert.That( + LogitechCenturionProtocol.BuildDirectRequest(0, 0x00, 0x00, 0x03), + Is.EqualTo(new byte[] { 0x51, 0x05, 0x00, 0x00, 0x01, 0x00, 0x03 }) + ); + Assert.That( + LogitechCenturionProtocol.BuildBridgeRequest(3, 4, 0x00), + Is.EqualTo(new byte[] { 0x51, 0x08, 0x00, 0x03, 0x11, 0x00, 0x03, 0x00, 0x04, 0x01 }) + ); + } + } + + [Test] + public async Task Reads_the_level_through_the_bridge() + { + var receiver = new FakeReceiver(level: 80, state: 0); + + var reading = await ReadAsync(receiver); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reading.Percent, Is.EqualTo(80)); + Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Discharging)); + Assert.That(receiver.Writes, Has.Count.EqualTo(3)); + } + } + + [TestCase((byte)1, BatteryStatus.Charging)] + [TestCase((byte)2, BatteryStatus.Charging)] + [TestCase((byte)3, BatteryStatus.Full)] + [TestCase((byte)9, BatteryStatus.Unknown)] + public async Task The_state_byte_gives_the_status(byte state, BatteryStatus status) + { + var reading = await ReadAsync(new FakeReceiver(level: 75, state)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reading.Percent, Is.EqualTo(75)); + Assert.That(reading.Status, Is.EqualTo(status)); + } + } + + [Test] + public void A_headset_that_is_off_only_gets_the_acknowledgement_and_is_no_reading() => + Assert.ThrowsAsync(() => ReadAsync(new FakeReceiver(80, 0, headsetOn: false))); + + [Test] + public void A_level_above_100_is_no_reading() => + Assert.ThrowsAsync(() => ReadAsync(new FakeReceiver(0xC8, 0))); + + [Test] + public void A_missing_feature_is_a_different_device() => + Assert.Throws(() => LogitechCenturionProtocol.ParseFeatureIndex([0x00])); + + [Test] + public void Another_function_or_a_notification_is_not_the_answer() + { + byte[] Report(params byte[] payload) => [0x51, (byte)(payload.Length + 1), 0x00, .. payload]; + + using (Assert.EnterMultipleScope()) + { + Assert.That(LogitechCenturionProtocol.IsBridgeReply(Report(3, 0x11), 3, 4, 0x00), Is.False); + Assert.That( + LogitechCenturionProtocol.IsBridgeReply(Report(3, 0x10, 0, 4, 0xFF, 4, 0x01, 5), 3, 4, 0x00), + Is.False + ); + Assert.That( + LogitechCenturionProtocol.IsBridgeReply(Report(3, 0x10, 0, 4, 0x00, 4, 0x11, 6), 3, 4, 0x00), + Is.False + ); + Assert.That(LogitechCenturionProtocol.IsDirectReply(Report(1, 0x01, 9), 1, 0x10), Is.False); + } + } +} From 89f1f54ee52dc0ffb9dfa03303b78025649b27ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Fri, 16 Oct 2026 21:52:49 +0200 Subject: [PATCH 2/2] docs: note the G Pro X 2 Lightspeed was read on hardware --- AGENTS.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 480f021..b029a97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -482,7 +482,9 @@ Design knowledge that is not obvious from the code alone: design (a raw USB id alone cannot drive the Razer HID protocol); an unlisted device is a model in a family. - **The Logitech G Pro X 2 Lightspeed (`0AF7`) is not HID++ but Logitech's "Centurion" framing**, mapped - from Solaar and HeadsetControl as HaloBattery documents them, not yet read through the plugin. + from Solaar and HeadsetControl as HaloBattery documents them, then read through the plugin's hardware + tests by the owner (receiver features `0000 0001 0100 0003 010A`, headset via bridge 3 `0000 0001 0101 + 0100 0104 010A 0108 0604 0602 0636`, the same as Solaar's dump; `59 59 00` = 89% matched G HUB). `Sources/Logitech/LogitechCenturionProtocol.cs` talks on `FFA0:0001` (64-byte reports, interface 3): a frame is `51 `, `len` = payload + 1. The receiver answers its own features directly (` `, an error is `FF `) and reaches the @@ -492,7 +494,8 @@ Design knowledge that is not obvious from the code alone: the bridge, Root getFeature for the headset's battery `0x0104` through the bridge, then its getStatus: byte 0 the level, byte 2 the state (`0` discharging, `1` charging, `2` charging over USB, `3` complete). A headset that is off leaves only the acknowledgement, so the read times out. - HaloBattery and Solaar find the indices by listing FeatureSet instead of asking Root for each id; + HaloBattery and Solaar find the indices by listing FeatureSet; asking Root for each id directly works + on the real headset too, and takes far fewer exchanges. `HardwareTests.Lists_the_centurion_features_of_the_logitech_headset` does that listing to show where a read stops. HeadsetControl's fixed request for firmware without `0x0104` is not implemented. - **The Corsair VOID headsets answer one output report.** `Sources/Corsair/CorsairProtocol.cs`