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
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,23 @@ 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, 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> <flags> <payload>`, `len` = payload + 1. The receiver answers its own features
directly (`<index> <function|sw id>`, an error is `FF <index> <function> <code>`) and reaches the
headset through its bridge feature `0x0003`: `<bridge> 11 <size> 00 <index> <function|sw id> ...`,
which the receiver first acknowledges (`<bridge> 11`) before the headset answers (`<bridge> 10 <size>
00 <index> <function> <data>`, 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; 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`
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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 2 additions & 0 deletions docs/linux-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"' \
Expand Down
2 changes: 2 additions & 0 deletions packaging/linux/70-device-battery-info.rules
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
175 changes: 175 additions & 0 deletions src/DeviceBatteryInfo/Sources/Logitech/LogitechCenturionProtocol.cs
Original file line number Diff line number Diff line change
@@ -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<HidDeviceInfo> Devices { get; } =
[new("G Pro X 2 Lightspeed", 0x0AF7) { Kind = BatterySourceKind.Headset }];

public override async Task<BatteryReading> 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<byte[]> 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<byte[]> 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<byte> 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<byte> 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),
};
}
}
83 changes: 83 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 LogitechCenturionProtocol(),
new LogitechVoltageBatteryProtocol(),
new CorsairProtocol(),
new RapooProtocol(),
Expand Down Expand Up @@ -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<ushort[]> CenturionFeaturesAsync(Func<byte, byte, byte[], Task<byte[]>> 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()
Expand Down
Loading
Loading