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
26 changes: 15 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,17 +560,21 @@ Design knowledge that is not obvious from the code alone:
irregular** (3 s bursts, then 30-60 s of silence, while the mouse is in use), so a 4 s read and the
probe often miss them: the probe then falls back to the silent dongle collection and the reading
goes stale. Accepted as a known limitation; the fix would be a background listener caching the last
report per path, instead of listening per poll. Over the dongle the push is a steady 3.2 s. It is the one protocol
that listens only, so `ExchangeReportsAsync` skips the write for an empty request, and it overrides
`HidProtocol.ReadBudget` (4 s), because the 400 ms probe and 2 s read budgets are shorter than the
push interval. A switched-off mouse is silent, so the read times out. PID `1215` is not the VT3 PRO
id that the open-source Rapoo tools document (`1231`, `14A5`), and their report layouts do not
apply to it. `Sources/Aula/AulaProtocol.cs` (F75 on the Compx receiver `3554:FA09`) sends the
battery-hub / womier-l65-linux frame on `FF02:0002`: report `0x13`, command `0x4A`, 20 bytes, and
the last byte is the byte sum of the rest. The reply has the level in byte 5 and the state in byte 6
(`0x01` on battery, `0x10` with the cable in). With the cable in the level always reads 100, even at
a real 97%, so that reads as Charging with no percent (charging and full cannot be told apart). The receiver also pushes
unsolicited `0x13 0A` frames, so a reply must match the command and the checksum. In wired mode the
report per path, instead of listening per poll. Over the dongle the push is a steady 3.2 s. It is
the one protocol that listens only, so `ExchangeReportsAsync` skips the write for an empty request,
and it overrides `HidProtocol.ReadBudget` (4 s), because the 400 ms probe and 2 s read budgets are
shorter than the push interval. A switched-off mouse is silent, so the read times out. PID `1215`
is not the VT3 PRO id that the open-source Rapoo tools document (`1231`, `14A5`), and their report
layouts do not apply to it. Rapoo office mice (`RapooOfficeProtocol`: MT760, dongle `1870`) talk
on vendor collection `FF00:000E` (32-byte reports, `HidReportKind.GetInputReport`): unlike the VT
series, they require an active query report `BA A5 AA 00...`, answered by input report
`01 <state> <level>...`. State 1 is on battery, 2 charging; level 1..100. `Sources/Aula/AulaProtocol.cs`
(F75 on the Compx receiver `3554:FA09`) sends the battery-hub / womier-l65-linux frame on
`FF02:0002`: report `0x13`, command `0x4A`, 20 bytes, and the last byte is the byte sum of the rest.
The reply has the level in byte 5 and the state in byte 6 (`0x01` on battery, `0x10` with the cable in).
With the cable in the level always reads 100, even at a real 97%, so that reads as Charging with no
percent (charging and full cannot be told apart). The receiver also pushes unsolicited `0x13 0A`
frames, so a reply must match the command and the checksum. In wired mode the
keyboard enumerates as a separate Sinowealth device (`258A:010C`), with an unknown protocol, and is
not read. Command `0x44` returns a 10-frame configuration dump (probably lighting) that is not
mapped. The 8BitDo Ultimate (`2DC8:3106`) is deliberately absent: its dongle presents an
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ listed here is not supported even if it shares a brand or protocol with one that
| 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. |
| Rapoo | MT760 | Mouse | Dongle | yes | yes | |
| AULA | F75 | Keyboard | 2.4G receiver | yes | yes | No level while the cable is in, only that it charges. Not read in wired mode. |
| Sony | DualSense | Controller | USB or Bluetooth | 10% steps | yes | The controller reports its level in steps of 10%. |
| MCHOSE | V9 Pro | Headset | Dongle | 10% steps | yes | No level while charging, only that it charges. It reads nothing while the headset is off. |
Expand Down
2 changes: 2 additions & 0 deletions docs/linux-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ printf '%s\n' \
'# Rapoo VT3 PRO' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="1215", TAG+="uaccess"' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="4415", TAG+="uaccess"' \
'# Rapoo MT760' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="1870", TAG+="uaccess"' \
'# Razer DeathAdder V3 Pro' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00b7", TAG+="uaccess"' \
'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00b6", 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 @@ -26,6 +26,8 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="291d", ATTRS{idProduct}=="385d", TAG+="ua
# Rapoo VT3 PRO
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="1215", TAG+="uaccess"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="4415", TAG+="uaccess"
# Rapoo MT760
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="1870", TAG+="uaccess"
# Razer DeathAdder V3 Pro
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00b7", TAG+="uaccess"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00b6", TAG+="uaccess"
Expand Down
23 changes: 18 additions & 5 deletions src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ internal enum HidReportKind

// Output report out, input reports back, on a vendor usage page (Logitech HID++).
InputOutput,

// Output report out, input report fetched with GetInputReport rather than streamed (Rapoo office).
GetInputReport,
}

internal sealed class HidChannel(
Expand All @@ -41,21 +44,31 @@ public Task<byte[]> ExchangeAsync(
Func<byte[], bool> isComplete,
CancellationToken cancellationToken
) =>
kind == HidReportKind.Feature
? transport.ExchangeAsync(
kind switch
{
HidReportKind.Feature => transport.ExchangeAsync(
DevicePath,
request,
isComplete,
budget ?? ReadBudget,
cancellationToken
),
HidReportKind.InputOutput => transport.ExchangeReportsAsync(
DevicePath,
request,
isComplete,
budget ?? ReadBudget,
cancellationToken
)
: transport.ExchangeReportsAsync(
),
HidReportKind.GetInputReport => transport.ExchangeGetInputReportAsync(
DevicePath,
request,
isComplete,
budget ?? ReadBudget,
cancellationToken
);
),
_ => throw new NotSupportedException($"Unsupported HID report kind: {kind}"),
};

public Task<byte[]> GetFeatureAsync(byte reportId, CancellationToken cancellationToken) =>
transport.GetFeatureAsync(DevicePath, reportId, budget ?? ReadBudget, cancellationToken);
Expand Down
143 changes: 142 additions & 1 deletion src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ Task<byte[]> ExchangeReportsAsync(
CancellationToken cancellationToken
);

// Writes an output report and fetches the input report directly rather than waiting for a stream.
Task<byte[]> ExchangeGetInputReportAsync(
string devicePath,
byte[] request,
Func<byte[], bool> isComplete,
TimeSpan budget,
CancellationToken cancellationToken
);

// Reads one feature report without writing anything first (unlike ExchangeAsync).
Task<byte[]> GetFeatureAsync(
string devicePath,
Expand Down Expand Up @@ -319,6 +328,20 @@ CancellationToken cancellationToken
cancellationToken
);

public Task<byte[]> ExchangeGetInputReportAsync(
string devicePath,
byte[] request,
Func<byte[], bool> isComplete,
TimeSpan budget,
CancellationToken cancellationToken
) =>
BoundAsync(
$"HID get-input-report exchange on {devicePath}",
budget,
ct => ExchangeGetInputReportsAsync(devicePath, request, isComplete, budget, ct),
cancellationToken
);

private async Task<byte[]> ExchangeInputOutputReportsAsync(
string devicePath,
byte[] request,
Expand Down Expand Up @@ -369,6 +392,65 @@ await Task.Run(
cancellationToken
);

private async Task<byte[]> ExchangeGetInputReportsAsync(
string devicePath,
byte[] request,
Func<byte[], bool> isComplete,
TimeSpan budget,
CancellationToken cancellationToken
) =>
await Task.Run(
() =>
{
using var stream = OpenReportStream(devicePath);
cancellationToken.ThrowIfCancellationRequested();

byte[]? output = null;
if (request.Length > 0)
{
output = new byte[Math.Max(stream.OutputReportLength(request[0]), request.Length)];
request.CopyTo(output, 0);
}

var clock = Stopwatch.StartNew();
var reportId = request.Length > 0 ? request[0] : (byte)0;
for (var attempt = 1; clock.Elapsed < budget; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();

if (output is not null)
{
stream.Write(output);
}

Thread.Sleep(SettleDelay(attempt));

if (stream.GetInputReport(reportId) is { } input)
{
if (isComplete(input))
{
return input;
}

_logger.Debug(
"HID get-input-report on {Path} not ready, attempt {Attempt}: {Response}",
devicePath,
attempt,
Convert.ToHexString(input)
);
}

Thread.Sleep(NextRetryDelay());
}

throw new InvalidOperationException(
$"HID get-input-report exchange on {devicePath} was not answered within "
+ $"{budget.TotalMilliseconds} ms."
);
},
cancellationToken
);

private static IReportStream OpenReportStream(string devicePath)
{
if (OperatingSystem.IsMacOS())
Expand Down Expand Up @@ -551,11 +633,19 @@ internal interface IReportStream : IDisposable

// Null when no report arrived within the timeout.
byte[]? Read(TimeSpan timeout);

byte[]? GetInputReport(byte reportId);
}

internal sealed class HidSharpReportStream(HidDevice device) : IReportStream
{
private readonly HidStream _stream = device.Open();
private readonly Lazy<Microsoft.Win32.SafeHandles.SafeFileHandle?> _nativeHandle = new(() =>
OperatingSystem.IsWindows() ? NativeHid.TryOpen(device.DevicePath) : null
);
private readonly Lazy<Microsoft.Win32.SafeHandles.SafeFileHandle?> _linuxHandle = new(() =>
OperatingSystem.IsLinux() ? LinuxHidraw.TryOpen(device.DevicePath) : null
);

public int OutputReportLength(byte reportId)
{
Expand Down Expand Up @@ -587,7 +677,42 @@ public int OutputReportLength(byte reportId)
}
}

public void Dispose() => _stream.Dispose();
public byte[]? GetInputReport(byte reportId)
{
var input = new byte[device.GetMaxInputReportLength()];
input[0] = reportId;

if (OperatingSystem.IsWindows() && _nativeHandle.Value is { } handle)
{
if (NativeHid.TryGetInputReport(handle, input))
{
return input;
}
}
else if (OperatingSystem.IsLinux() && _linuxHandle.Value is { } linuxHandle)
{
if (LinuxHidraw.TryGetInputReport(linuxHandle, input))
{
return input;
}
}

return null;
}

public void Dispose()
{
_stream.Dispose();
if (_nativeHandle.IsValueCreated && _nativeHandle.Value is { } handle)
{
handle.Dispose();
}

if (_linuxHandle.IsValueCreated && _linuxHandle.Value is { } linuxHandle)
{
linuxHandle.Dispose();
}
}
}

[System.Runtime.Versioning.SupportedOSPlatform("macos")]
Expand Down Expand Up @@ -619,5 +744,21 @@ public int OutputReportLength(byte reportId) =>

public byte[]? Read(TimeSpan timeout) => _device.ReadInputReport(timeout);

public byte[]? GetInputReport(byte reportId)
{
var longest = _descriptor?.MaxInputReportLength ?? 0;
var input = new byte[Math.Max(longest, 64)];
input[0] = reportId;
try
{
_device.GetInputReport(input);
return input;
}
catch (IOException)
{
return null;
}
}

public void Dispose() => _device.Dispose();
}
32 changes: 32 additions & 0 deletions src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ internal static class LinuxHidraw
private const uint HidrawType = 'H';
private const uint SetFeatureNumber = 0x06;
private const uint GetFeatureNumber = 0x07;
private const uint GetInputNumber = 0x0A;
private const int MaxReportLength = 0x3FFF;

// Classic DllImport, matching NativeHid. ioctl is variadic, which x64 and arm64 Linux pass like fixed args.
Expand All @@ -33,6 +34,19 @@ internal static string DeviceNode(string devicePath)
internal static SafeFileHandle Open(string devicePath) =>
File.OpenHandle(DeviceNode(devicePath), FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);

[SupportedOSPlatform("linux")]
internal static SafeFileHandle? TryOpen(string devicePath)
{
try
{
return Open(devicePath);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return null;
}
}

[SupportedOSPlatform("linux")]
internal static void SetFeature(SafeFileHandle handle, byte[] report) =>
Call(handle, SetFeatureNumber, report, "HIDIOCSFEATURE");
Expand All @@ -41,6 +55,24 @@ internal static void SetFeature(SafeFileHandle handle, byte[] report) =>
internal static void GetFeature(SafeFileHandle handle, byte[] report) =>
Call(handle, GetFeatureNumber, report, "HIDIOCGFEATURE");

[SupportedOSPlatform("linux")]
internal static void GetInput(SafeFileHandle handle, byte[] report) =>
Call(handle, GetInputNumber, report, "HIDIOCGINPUT");

[SupportedOSPlatform("linux")]
internal static bool TryGetInputReport(SafeFileHandle handle, byte[] report)
{
try
{
GetInput(handle, report);
return true;
}
catch (IOException)
{
return false;
}
}

// The size field has 14 bits; a longer length would spill into the direction bits and name another ioctl.
internal static nuint Request(uint number, int length) =>
length is > 0 and <= MaxReportLength
Expand Down
19 changes: 19 additions & 0 deletions src/DeviceBatteryInfo/Sources/Hid/MacHid.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ internal sealed record MacHidEntry(

internal enum MacHidReportType
{
Input = 0,
Output = 1,
Feature = 2,
}
Expand Down Expand Up @@ -437,6 +438,24 @@ public void GetFeature(byte[] report)
data.AsSpan(0, (int)read).CopyTo(report.AsSpan(1));
}

public void GetInputReport(byte[] report)
{
if (report[0] != 0)
{
nint length = report.Length;
Check(
IOHIDDeviceGetReport(_device, (int)MacHidReportType.Input, report[0], report, ref length),
"IOHIDDeviceGetReport"
);
return;
}

var data = new byte[report.Length - 1];
nint read = data.Length;
Check(IOHIDDeviceGetReport(_device, (int)MacHidReportType.Input, 0, data, ref read), "IOHIDDeviceGetReport");
data.AsSpan(0, (int)read).CopyTo(report.AsSpan(1));
}

// Input reports are delivered on the run loop of the thread that calls this, so read them on that thread.
public void ListenForInputReports(int maxReportLength, bool prependReportId)
{
Expand Down
Loading
Loading