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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
3 changes: 3 additions & 0 deletions docs/linux-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down
3 changes: 3 additions & 0 deletions packaging/linux/70-device-battery-info.rules
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ CancellationToken cancellationToken
return known;
}

HidCandidate? fallback = null;
foreach (var candidate in candidates)
{
try
Expand All @@ -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];
}
}
5 changes: 4 additions & 1 deletion src/DeviceBatteryInfo/Sources/Logitech/LogitechProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ internal sealed class LogitechProtocol() : LogitechHidppProtocol(0xFF00, 0x0002,
protected override byte BatteryFunction => 0x01;

public override IReadOnlyList<HidDeviceInfo> 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<byte> report) => ParseStatus(report);

Expand Down
120 changes: 119 additions & 1 deletion tests/DeviceBatteryInfo.Tests/HardwareTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -201,6 +209,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<HidCandidate> 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<string> 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<string>();
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<byte> FeatureIndexAsync(HidChannel channel, byte deviceIndex, ushort featureId) =>
LogitechHidppProtocol.ParseFeatureIndex(
await CallHidppAsync(channel, deviceIndex, 0x00, 0x00, (byte)(featureId >> 8), (byte)featureId)
);

private static async Task<byte[]> 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()
Expand Down Expand Up @@ -292,6 +397,19 @@ private static void Hammer(HidCandidate candidate, CancellationToken cancellatio
}
}

private sealed class ProbeLog : Serilog.Core.ILogEventSink
{
private readonly System.Collections.Concurrent.ConcurrentQueue<string> _lines = new();

public IEnumerable<string> Lines => _lines;

public void Emit(Serilog.Events.LogEvent logEvent) =>
_lines.Enqueue(
logEvent.RenderMessage(System.Globalization.CultureInfo.InvariantCulture)
+ (logEvent.Exception is { } exception ? $" ({exception.Message})" : "")
);
}

private sealed class RetryCounter : Serilog.Core.ILogEventSink
{
private int _count;
Expand Down
17 changes: 17 additions & 0 deletions tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<InvalidOperationException>(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()
{
Expand Down
Loading