From 05b0a58a9591ae33df49e0dcb19d4167027f47ef Mon Sep 17 00:00:00 2001 From: Saksham Singh Date: Wed, 28 Oct 2026 21:30:25 +0530 Subject: [PATCH 1/4] feat: support the Rapoo MT760 wireless mouse Add RapooOfficeProtocol for the Rapoo MT760 and MT760 Mini wireless mice over the 2.4GHz USB receiver (24AE:1870). - Talk on vendor collection FF00:000E with 32-byte reports and an active query frame (BA A5 AA 00...) - Add HidD_GetInputReport fallback in NativeHid and HidTransport for Windows input report queries - Add unit tests for request framing and battery parsing - Register MT760 in HardwareTests and Linux udev rules - Update README and AGENTS.md documentation --- AGENTS.md | 5 +- README.md | 1 + docs/linux-setup.md | 2 + packaging/linux/70-device-battery-info.rules | 2 + .../Sources/Hid/HidTransport.cs | 30 +++++-- .../Sources/Hid/NativeHid.cs | 11 +++ .../Sources/Rapoo/RapooProtocol.cs | 82 +++++++++++++++++++ .../DeviceBatteryInfo.Tests/HardwareTests.cs | 1 + .../RapooProtocolTests.cs | 61 ++++++++++++++ 9 files changed, 189 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 176d6fb..a493e2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -565,7 +565,10 @@ Design knowledge that is not obvious from the code alone: `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 + apply to it. Rapoo office mice (`RapooOfficeProtocol`: MT760, MT760 Mini, dongle `1870`) talk on vendor + collection `FF00:000E` (32-byte reports): unlike the VT series, they require an active query report + `BA A5 AA 00...`, answered by input report `01 ...` (or `BA 01 `). 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 diff --git a/README.md b/README.md index dc814b9..6161fe7 100644 --- a/README.md +++ b/README.md @@ -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 | 2.4G dongle | yes | yes | Live percentage and charging state over the 2.4GHz USB receiver. | | 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. | diff --git a/docs/linux-setup.md b/docs/linux-setup.md index 679c525..b123872 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -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"' \ diff --git a/packaging/linux/70-device-battery-info.rules b/packaging/linux/70-device-battery-info.rules index 6fe6471..b17c869 100644 --- a/packaging/linux/70-device-battery-info.rules +++ b/packaging/linux/70-device-battery-info.rules @@ -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" diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs index 5ba1be1..0e6b966 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs @@ -341,11 +341,12 @@ await Task.Run( } var clock = Stopwatch.StartNew(); + var reportId = request.Length > 0 ? request[0] : (byte)0; while (clock.Elapsed < budget) { cancellationToken.ThrowIfCancellationRequested(); - if (stream.Read(ReportReadTimeout) is not { } input) + if (stream.Read(ReportReadTimeout, reportId) is not { } input) { continue; } @@ -550,12 +551,15 @@ internal interface IReportStream : IDisposable void Write(byte[] report); // Null when no report arrived within the timeout. - byte[]? Read(TimeSpan timeout); + byte[]? Read(TimeSpan timeout, byte reportId = 0); } internal sealed class HidSharpReportStream(HidDevice device) : IReportStream { private readonly HidStream _stream = device.Open(); + private readonly Lazy _nativeHandle = new(() => + OperatingSystem.IsWindows() ? NativeHid.Open(device.DevicePath) : null + ); public int OutputReportLength(byte reportId) { @@ -572,7 +576,7 @@ public int OutputReportLength(byte reportId) public void Write(byte[] report) => _stream.Write(report); - public byte[]? Read(TimeSpan timeout) + public byte[]? Read(TimeSpan timeout, byte reportId = 0) { _stream.ReadTimeout = (int)timeout.TotalMilliseconds; var input = new byte[device.GetMaxInputReportLength()]; @@ -583,11 +587,27 @@ public int OutputReportLength(byte reportId) } catch (TimeoutException) { + if (OperatingSystem.IsWindows() && _nativeHandle.Value is { } handle) + { + input[0] = reportId; + if (NativeHid.TryGetInputReport(handle, input)) + { + return input; + } + } + return null; } } - public void Dispose() => _stream.Dispose(); + public void Dispose() + { + _stream.Dispose(); + if (_nativeHandle.IsValueCreated && _nativeHandle.Value is { } handle) + { + handle.Dispose(); + } + } } [System.Runtime.Versioning.SupportedOSPlatform("macos")] @@ -617,7 +637,7 @@ public int OutputReportLength(byte reportId) => public void Write(byte[] report) => _device.SetReport(MacHidReportType.Output, report); - public byte[]? Read(TimeSpan timeout) => _device.ReadInputReport(timeout); + public byte[]? Read(TimeSpan timeout, byte reportId = 0) => _device.ReadInputReport(timeout); public void Dispose() => _device.Dispose(); } diff --git a/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs b/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs index 9414587..8bf57ef 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs @@ -50,6 +50,14 @@ private static extern bool HidD_GetFeature( uint bufferLength ); + [DllImport("hid.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool HidD_GetInputReport( + SafeFileHandle device, + byte[] buffer, + uint bufferLength + ); + internal static SafeFileHandle Open(string devicePath) { var handle = CreateFile( @@ -101,4 +109,7 @@ internal static void GetFeature(SafeFileHandle device, byte[] report) throw new Win32Exception(Marshal.GetLastWin32Error(), "HidD_GetFeature failed."); } } + + internal static bool TryGetInputReport(SafeFileHandle device, byte[] report) => + HidD_GetInputReport(device, report, (uint)report.Length); } diff --git a/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs b/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs index e54e86c..9878763 100644 --- a/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs @@ -58,3 +58,85 @@ internal static BatteryReading ParseStatus(ReadOnlySpan report) }; } } + +// MT-series office mice answer an active query on vendor collection FF00:000E. +internal sealed class RapooOfficeProtocol() + : HidProtocol( + "Rapoo", + vendorId: 0x24AE, + reportLength: FrameLength, + HidReportKind.InputOutput, + usagePage: 0xFF00, + usage: 0x000E + ) +{ + private const int FrameLength = 32; + private const byte ReportId = 0xBA; + private const byte VendorPrefix = 0xA5; + private const byte BatteryCommand = 0xAA; + + private const byte StatusSuccess = 0x01; + private const byte OnBattery = 0x01; + private const byte Charging = 0x02; + + public override IReadOnlyList Devices { get; } = + [new("MT760", 0x1870), new("MT760 Mini", 0x1870)]; + + public override TimeSpan? ReadBudget => TimeSpan.FromSeconds(2); + + public override async Task ReadAsync( + HidChannel channel, + HidDeviceInfo device, + CancellationToken cancellationToken + ) => ParseBattery(await channel.ExchangeAsync(BuildBatteryRequest(), IsBatteryReply, cancellationToken)); + + internal static byte[] BuildBatteryRequest() => + [ + ReportId, + VendorPrefix, + BatteryCommand, + .. new byte[FrameLength - 3], + ]; + + internal static bool IsBatteryReply(byte[] report) + { + if (report.Length < 3) + { + return false; + } + + // The driver may include or strip the 0xBA report ID at index 0. + if (report[0] == ReportId && report.Length >= 4) + { + return report[1] == StatusSuccess && report[3] > 0 && report[3] <= 100; + } + + return report[0] == StatusSuccess && report[2] > 0 && report[2] <= 100; + } + + internal static BatteryReading ParseBattery(ReadOnlySpan report) + { + int offset = (report.Length >= 4 && report[0] == ReportId) ? 1 : 0; + var status = report[offset]; + if (status != StatusSuccess) + { + throw new InvalidOperationException($"The mouse reported non-success status 0x{status:X2}."); + } + + var state = report[offset + 1]; + var level = report[offset + 2]; + + if (level == 0 || level > 100) + { + throw new InvalidOperationException($"The mouse reported invalid battery percentage {level}%."); + } + + return state switch + { + Charging when level >= 100 => BatteryReading.FromPercent(level, BatteryStatus.Full), + Charging => BatteryReading.FromPercent(level, BatteryStatus.Charging), + OnBattery => BatteryReading.FromPercent(level, BatteryStatus.Discharging), + _ => BatteryReading.FromPercent(level), + }; + } +} diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index b35cad2..f574f14 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -30,6 +30,7 @@ public sealed class HardwareTests new LogitechVoltageBatteryProtocol(), new CorsairProtocol(), new RapooProtocol(), + new RapooOfficeProtocol(), new AulaProtocol(), new SonyProtocol(), new MchoseProtocol(), diff --git a/tests/DeviceBatteryInfo.Tests/RapooProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/RapooProtocolTests.cs index c2faa11..95225a4 100644 --- a/tests/DeviceBatteryInfo.Tests/RapooProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/RapooProtocolTests.cs @@ -45,4 +45,65 @@ public void Charging_at_100_is_full() => [Test] public void Only_the_status_report_counts() => Assert.That(RapooProtocol.IsStatusReport([0x07, 0, 0, 0, 0, 1, 50]), Is.False); + + [Test] + public void Builds_correct_mt760_query_request() + { + var request = RapooOfficeProtocol.BuildBatteryRequest(); + Assert.That(request.Length, Is.EqualTo(32)); + Assert.That(request[0..3], Is.EqualTo(new byte[] { 0xBA, 0xA5, 0xAA })); + } + + [Test] + public void Reads_mt760_battery_level_and_discharging_state() + { + // Live response captured from MT760: 0x01 (status), 0x01 (discharging), 0x42 (66%) + byte[] liveResponse = [0x01, 0x01, 0x42, .. new byte[29]]; + Assert.That(RapooOfficeProtocol.IsBatteryReply(liveResponse), Is.True); + + var reading = RapooOfficeProtocol.ParseBattery(liveResponse); + using (Assert.EnterMultipleScope()) + { + Assert.That(reading.Percent, Is.EqualTo(66)); + Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Discharging)); + } + } + + [Test] + public void Reads_mt760_battery_level_with_report_id() + { + byte[] responseWithId = [0xBA, 0x01, 0x01, 0x42, .. new byte[28]]; + Assert.That(RapooOfficeProtocol.IsBatteryReply(responseWithId), Is.True); + + var reading = RapooOfficeProtocol.ParseBattery(responseWithId); + using (Assert.EnterMultipleScope()) + { + Assert.That(reading.Percent, Is.EqualTo(66)); + Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Discharging)); + } + } + + [Test] + public void Reads_mt760_charging_and_full_states() + { + byte[] chargingResponse = [0x01, 0x02, 85, .. new byte[29]]; + var reading = RapooOfficeProtocol.ParseBattery(chargingResponse); + Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Charging)); + + byte[] fullResponse = [0x01, 0x02, 100, .. new byte[29]]; + var fullReading = RapooOfficeProtocol.ParseBattery(fullResponse); + Assert.That(fullReading.Status, Is.EqualTo(BatteryStatus.Full)); + } + + [Test] + public void Mt760_rejects_unrelated_or_invalid_reports() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(RapooOfficeProtocol.IsBatteryReply([0x00, 0x01, 50, .. new byte[29]]), Is.False); + Assert.That(RapooOfficeProtocol.IsBatteryReply([0x01, 0x01, 0, .. new byte[29]]), Is.False); + Assert.That(RapooOfficeProtocol.IsBatteryReply([0x01, 0x01, 101, .. new byte[29]]), Is.False); + Assert.That(RapooOfficeProtocol.IsBatteryReply([0xBA, 0x00, 0x01, 50, .. new byte[28]]), Is.False); + } + } } From d847772defbb0d39af3fc25054c1dacf39d19f46 Mon Sep 17 00:00:00 2001 From: Saksham Singh Date: Sat, 31 Oct 2026 00:07:15 +0530 Subject: [PATCH 2/4] refactor: scope input report fallback and refine Rapoo MT760 support - Make HidD_GetInputReport fallback opt-in via HidProtocol.FallbackToGetInputReport - Use safe NativeHid.TryOpen to avoid uncaught exceptions during handle creation - Simplify RapooOfficeProtocol parser to single report layout [01, state, level] - Drop unverified MT760 Mini entry and redundant ReadBudget override - Update README table and re-wrap AGENTS.md to match repo conventions --- AGENTS.md | 29 +++++----- README.md | 2 +- .../Sources/Hid/HidFamily.cs | 6 +- .../Sources/Hid/HidProtocol.cs | 8 ++- .../Sources/Hid/HidTransport.cs | 35 +++++++++--- .../Sources/Hid/NativeHid.cs | 42 ++++++++++++++ .../Sources/Rapoo/RapooProtocol.cs | 30 +++------- .../DeviceBatteryInfo.Tests/HardwareTests.cs | 56 +++++++++++++++++++ .../RapooProtocolTests.cs | 16 +----- 9 files changed, 161 insertions(+), 63 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a493e2f..97c0eef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -560,20 +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. Rapoo office mice (`RapooOfficeProtocol`: MT760, MT760 Mini, dongle `1870`) talk on vendor - collection `FF00:000E` (32-byte reports): unlike the VT series, they require an active query report - `BA A5 AA 00...`, answered by input report `01 ...` (or `BA 01 `). 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 + 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): unlike the VT series, they require an active + query report `BA A5 AA 00...`, answered by input report `01 ...`. 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 diff --git a/README.md b/README.md index 6161fe7..e0700d8 100644 --- a/README.md +++ b/README.md @@ -117,7 +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 | 2.4G dongle | yes | yes | Live percentage and charging state over the 2.4GHz USB receiver. | +| 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. | diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs b/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs index bd08715..2b69d6e 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs @@ -84,7 +84,8 @@ .. found.Where(c => !c.CouldNotOpen && Matches(model.Protocol, c)), candidate.Path, candidate.ProductId, model.Protocol.ReportKind, - model.Protocol.ReadBudget + model.Protocol.ReadBudget, + model.Protocol.FallbackToGetInputReport ); sources.Add( new DelegateBatterySource( @@ -200,7 +201,8 @@ CancellationToken cancellationToken candidate.ProductId, model.Protocol.ReportKind, // A device that only pushes needs its whole interval even to be found. - model.Protocol.ReadBudget ?? HidChannel.ProbeBudget + model.Protocol.ReadBudget ?? HidChannel.ProbeBudget, + model.Protocol.FallbackToGetInputReport ); _ = await model.Protocol.ReadAsync(channel, model.Device, cancellationToken); _resolvedPaths[slot.Id] = candidate.Path; diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs b/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs index 097e08e..5dcae34 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs @@ -22,7 +22,8 @@ internal sealed class HidChannel( string devicePath, int productId, HidReportKind kind = HidReportKind.Feature, - TimeSpan? budget = null + TimeSpan? budget = null, + bool fallbackToGetInputReport = false ) { public static readonly TimeSpan ReadBudget = TimeSpan.FromMilliseconds(2000); @@ -54,6 +55,7 @@ CancellationToken cancellationToken request, isComplete, budget ?? ReadBudget, + fallbackToGetInputReport, cancellationToken ); @@ -89,6 +91,10 @@ internal abstract class HidProtocol( // on its own schedule, slower than the default budgets. public virtual TimeSpan? ReadBudget => null; + // Some HID devices (such as Rapoo office mice) do not send interrupt input reports + // upon query frames; their reports can only be retrieved via HidD_GetInputReport. + public virtual bool FallbackToGetInputReport => false; + public abstract IReadOnlyList Devices { get; } // Send the request through the channel and parse the response. Throw when the device does not diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs index 0e6b966..59ce359 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs @@ -63,6 +63,15 @@ Task ExchangeReportsAsync( CancellationToken cancellationToken ); + Task ExchangeReportsAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + bool fallbackToGetInputReport, + CancellationToken cancellationToken + ) => ExchangeReportsAsync(devicePath, request, isComplete, budget, cancellationToken); + // Reads one feature report without writing anything first (unlike ExchangeAsync). Task GetFeatureAsync( string devicePath, @@ -311,11 +320,20 @@ public Task ExchangeReportsAsync( Func isComplete, TimeSpan budget, CancellationToken cancellationToken + ) => ExchangeReportsAsync(devicePath, request, isComplete, budget, fallbackToGetInputReport: false, cancellationToken); + + public Task ExchangeReportsAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + bool fallbackToGetInputReport, + CancellationToken cancellationToken ) => BoundAsync( $"HID report exchange on {devicePath}", budget, - ct => ExchangeInputOutputReportsAsync(devicePath, request, isComplete, budget, ct), + ct => ExchangeInputOutputReportsAsync(devicePath, request, isComplete, budget, ct, fallbackToGetInputReport), cancellationToken ); @@ -324,12 +342,13 @@ private async Task ExchangeInputOutputReportsAsync( byte[] request, Func isComplete, TimeSpan budget, - CancellationToken cancellationToken + CancellationToken cancellationToken, + bool fallbackToGetInputReport = false ) => await Task.Run( () => { - using var stream = OpenReportStream(devicePath); + using var stream = OpenReportStream(devicePath, fallbackToGetInputReport); cancellationToken.ThrowIfCancellationRequested(); // An empty request only listens, for a device that pushes its state on its own (Rapoo). @@ -370,7 +389,7 @@ await Task.Run( cancellationToken ); - private static IReportStream OpenReportStream(string devicePath) + private static IReportStream OpenReportStream(string devicePath, bool fallbackToGetInputReport = false) { if (OperatingSystem.IsMacOS()) { @@ -381,7 +400,7 @@ private static IReportStream OpenReportStream(string devicePath) var device = FindHidSharpDevice(devicePath) ?? throw new InvalidOperationException($"HID device {devicePath} is not present."); - return new HidSharpReportStream(device); + return new HidSharpReportStream(device, fallbackToGetInputReport); } // Off Windows one device carries every collection, so its longest output report is not the length of the @@ -554,11 +573,11 @@ internal interface IReportStream : IDisposable byte[]? Read(TimeSpan timeout, byte reportId = 0); } -internal sealed class HidSharpReportStream(HidDevice device) : IReportStream +internal sealed class HidSharpReportStream(HidDevice device, bool fallbackToGetInputReport = false) : IReportStream { private readonly HidStream _stream = device.Open(); private readonly Lazy _nativeHandle = new(() => - OperatingSystem.IsWindows() ? NativeHid.Open(device.DevicePath) : null + OperatingSystem.IsWindows() && fallbackToGetInputReport ? NativeHid.TryOpen(device.DevicePath) : null ); public int OutputReportLength(byte reportId) @@ -587,7 +606,7 @@ public int OutputReportLength(byte reportId) } catch (TimeoutException) { - if (OperatingSystem.IsWindows() && _nativeHandle.Value is { } handle) + if (fallbackToGetInputReport && OperatingSystem.IsWindows() && _nativeHandle.Value is { } handle) { input[0] = reportId; if (NativeHid.TryGetInputReport(handle, input)) diff --git a/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs b/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs index 8bf57ef..7147c40 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs @@ -94,6 +94,48 @@ internal static SafeFileHandle Open(string devicePath) return handle; } + internal static SafeFileHandle? TryOpen(string devicePath) + { + try + { + var handle = CreateFile( + devicePath, + GenericRead | GenericWrite, + FileShareRead | FileShareWrite, + securityAttributes: 0, + OpenExisting, + flagsAndAttributes: 0, + templateFile: 0 + ); + + if (handle.IsInvalid) + { + handle.Dispose(); + handle = CreateFile( + devicePath, + desiredAccess: 0, + FileShareRead | FileShareWrite, + securityAttributes: 0, + OpenExisting, + flagsAndAttributes: 0, + templateFile: 0 + ); + } + + if (handle.IsInvalid) + { + handle.Dispose(); + return null; + } + + return handle; + } + catch (Exception) + { + return null; + } + } + internal static void SetFeature(SafeFileHandle device, byte[] report) { if (!HidD_SetFeature(device, report, (uint)report.Length)) diff --git a/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs b/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs index 9878763..a3cbce4 100644 --- a/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs @@ -80,9 +80,9 @@ internal sealed class RapooOfficeProtocol() private const byte Charging = 0x02; public override IReadOnlyList Devices { get; } = - [new("MT760", 0x1870), new("MT760 Mini", 0x1870)]; + [new("MT760", 0x1870)]; - public override TimeSpan? ReadBudget => TimeSpan.FromSeconds(2); + public override bool FallbackToGetInputReport => true; public override async Task ReadAsync( HidChannel channel, @@ -98,35 +98,21 @@ internal static byte[] BuildBatteryRequest() => .. new byte[FrameLength - 3], ]; - internal static bool IsBatteryReply(byte[] report) - { - if (report.Length < 3) - { - return false; - } - - // The driver may include or strip the 0xBA report ID at index 0. - if (report[0] == ReportId && report.Length >= 4) - { - return report[1] == StatusSuccess && report[3] > 0 && report[3] <= 100; - } - - return report[0] == StatusSuccess && report[2] > 0 && report[2] <= 100; - } + internal static bool IsBatteryReply(byte[] report) => + report.Length >= 3 && report[0] == StatusSuccess && report[2] is > 0 and <= 100; internal static BatteryReading ParseBattery(ReadOnlySpan report) { - int offset = (report.Length >= 4 && report[0] == ReportId) ? 1 : 0; - var status = report[offset]; + var status = report[0]; if (status != StatusSuccess) { throw new InvalidOperationException($"The mouse reported non-success status 0x{status:X2}."); } - var state = report[offset + 1]; - var level = report[offset + 2]; + var state = report[1]; + var level = report[2]; - if (level == 0 || level > 100) + if (level is 0 or > 100) { throw new InvalidOperationException($"The mouse reported invalid battery percentage {level}%."); } diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index f574f14..785b1f7 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -212,6 +212,62 @@ public async Task Reads_the_raw_mchose_battery_report() } } + [Test] + public async Task Reads_the_raw_rapoo_office_battery_report() + { + var protocol = new RapooOfficeProtocol(); + 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 Rapoo office dongle is connected."); + + foreach (var candidate in candidates) + { + var subject = $"Rapoo {DateTime.Now:HH:mm:ss}"; + var channel = new HidChannel( + transport, + candidate.Path, + candidate.ProductId, + protocol.ReportKind, + fallbackToGetInputReport: protocol.FallbackToGetInputReport + ); + try + { + var report = await channel.ExchangeAsync( + RapooOfficeProtocol.BuildBatteryRequest(), + RapooOfficeProtocol.IsBatteryReply, + CancellationToken.None + ); + string parsed; + try + { + var reading = RapooOfficeProtocol.ParseBattery(report); + parsed = $"{reading.Percent?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"}% {reading.Status}"; + } + catch (InvalidOperationException exception) + { + parsed = exception.Message; + } + + HardwareReport.Line( + subject, + $"{string.Join(" ", report.Take(16).Select(b => b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture)))} = {parsed}" + ); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + HardwareReport.Line(subject, "no answer: " + exception.Message); + } + } + } + [Test] public async Task Lists_the_hidpp_features_of_every_logitech_device() { diff --git a/tests/DeviceBatteryInfo.Tests/RapooProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/RapooProtocolTests.cs index 95225a4..44c680a 100644 --- a/tests/DeviceBatteryInfo.Tests/RapooProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/RapooProtocolTests.cs @@ -69,20 +69,6 @@ public void Reads_mt760_battery_level_and_discharging_state() } } - [Test] - public void Reads_mt760_battery_level_with_report_id() - { - byte[] responseWithId = [0xBA, 0x01, 0x01, 0x42, .. new byte[28]]; - Assert.That(RapooOfficeProtocol.IsBatteryReply(responseWithId), Is.True); - - var reading = RapooOfficeProtocol.ParseBattery(responseWithId); - using (Assert.EnterMultipleScope()) - { - Assert.That(reading.Percent, Is.EqualTo(66)); - Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Discharging)); - } - } - [Test] public void Reads_mt760_charging_and_full_states() { @@ -103,7 +89,7 @@ public void Mt760_rejects_unrelated_or_invalid_reports() Assert.That(RapooOfficeProtocol.IsBatteryReply([0x00, 0x01, 50, .. new byte[29]]), Is.False); Assert.That(RapooOfficeProtocol.IsBatteryReply([0x01, 0x01, 0, .. new byte[29]]), Is.False); Assert.That(RapooOfficeProtocol.IsBatteryReply([0x01, 0x01, 101, .. new byte[29]]), Is.False); - Assert.That(RapooOfficeProtocol.IsBatteryReply([0xBA, 0x00, 0x01, 50, .. new byte[28]]), Is.False); + Assert.That(RapooOfficeProtocol.IsBatteryReply([0x01, 0x00]), Is.False); } } } From 759bebb51c8b9aa37ec1f7cfc1278d99fdcc99fa Mon Sep 17 00:00:00 2001 From: Saksham Singh Date: Sat, 31 Oct 2026 10:10:51 +0530 Subject: [PATCH 3/4] refactor: model GetInputReport in HidReportKind and scope MT760 to Windows Address maintainer review feedback on PR #36: - Add HidReportKind.GetInputReport and dispatch directly in HidChannel.ExchangeAsync. - Remove FallbackToGetInputReport flag and redundant method overloads from HidTransport and HidFamily. - Keep IReportStream.Read(timeout) minimal without extra reportId or timeout parameters. - Implement ExchangeGetInputReportsAsync on HidSharpTransport with attempt retry and settle delay. - Drop Rapoo MT760 udev rules from packaging and docs; note in README.md that it is read on Windows only. - Add SupportedOnLinux property to HidProtocol (default true, false on RapooOfficeProtocol) to keep LinuxSourceTests strict for Linux-supported devices without unverified udev rules. - Simplify NativeHid.TryOpen with clean Win32Exception handling. - Re-wrap lines to maintain ~100-character code conventions. --- AGENTS.md | 18 +-- README.md | 2 +- docs/linux-setup.md | 2 - packaging/linux/70-device-battery-info.rules | 2 - .../Sources/Hid/HidFamily.cs | 6 +- .../Sources/Hid/HidProtocol.cs | 32 ++-- .../Sources/Hid/HidTransport.cs | 148 ++++++++++++++---- .../Sources/Hid/LinuxHidraw.cs | 32 ++++ src/DeviceBatteryInfo/Sources/Hid/MacHid.cs | 19 +++ .../Sources/Hid/NativeHid.cs | 34 +--- .../Sources/Rapoo/RapooProtocol.cs | 4 +- .../DeviceBatteryInfo.Tests/HardwareTests.cs | 20 ++- .../LinuxSourceTests.cs | 1 + 13 files changed, 222 insertions(+), 98 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 97c0eef..5b710e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -566,15 +566,15 @@ Design knowledge that is not obvious from the code alone: 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): unlike the VT series, they require an active - query report `BA A5 AA 00...`, answered by input report `01 ...`. 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 + on vendor collection `FF00:000E` (32-byte reports, `HidReportKind.GetInputReport`) on Windows: + unlike the VT series, they require an active query report `BA A5 AA 00...`, answered by input + report `01 ...`. 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 diff --git a/README.md b/README.md index e0700d8..de89fde 100644 --- a/README.md +++ b/README.md @@ -117,7 +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 | | +| Rapoo | MT760 | Mouse | Dongle | yes | yes | Read on Windows only. | | 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. | diff --git a/docs/linux-setup.md b/docs/linux-setup.md index b123872..679c525 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -56,8 +56,6 @@ 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"' \ diff --git a/packaging/linux/70-device-battery-info.rules b/packaging/linux/70-device-battery-info.rules index b17c869..6fe6471 100644 --- a/packaging/linux/70-device-battery-info.rules +++ b/packaging/linux/70-device-battery-info.rules @@ -26,8 +26,6 @@ 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" diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs b/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs index 2b69d6e..bd08715 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs @@ -84,8 +84,7 @@ .. found.Where(c => !c.CouldNotOpen && Matches(model.Protocol, c)), candidate.Path, candidate.ProductId, model.Protocol.ReportKind, - model.Protocol.ReadBudget, - model.Protocol.FallbackToGetInputReport + model.Protocol.ReadBudget ); sources.Add( new DelegateBatterySource( @@ -201,8 +200,7 @@ CancellationToken cancellationToken candidate.ProductId, model.Protocol.ReportKind, // A device that only pushes needs its whole interval even to be found. - model.Protocol.ReadBudget ?? HidChannel.ProbeBudget, - model.Protocol.FallbackToGetInputReport + model.Protocol.ReadBudget ?? HidChannel.ProbeBudget ); _ = await model.Protocol.ReadAsync(channel, model.Device, cancellationToken); _resolvedPaths[slot.Id] = candidate.Path; diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs b/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs index 5dcae34..f5ec2d1 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs @@ -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( @@ -22,8 +25,7 @@ internal sealed class HidChannel( string devicePath, int productId, HidReportKind kind = HidReportKind.Feature, - TimeSpan? budget = null, - bool fallbackToGetInputReport = false + TimeSpan? budget = null ) { public static readonly TimeSpan ReadBudget = TimeSpan.FromMilliseconds(2000); @@ -42,22 +44,31 @@ public Task ExchangeAsync( Func 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, - fallbackToGetInputReport, cancellationToken - ); + ), + _ => throw new NotSupportedException($"Unsupported HID report kind: {kind}"), + }; public Task GetFeatureAsync(byte reportId, CancellationToken cancellationToken) => transport.GetFeatureAsync(DevicePath, reportId, budget ?? ReadBudget, cancellationToken); @@ -91,9 +102,8 @@ internal abstract class HidProtocol( // on its own schedule, slower than the default budgets. public virtual TimeSpan? ReadBudget => null; - // Some HID devices (such as Rapoo office mice) do not send interrupt input reports - // upon query frames; their reports can only be retrieved via HidD_GetInputReport. - public virtual bool FallbackToGetInputReport => false; + // Override to false for a device that is read on Windows only. + public virtual bool SupportedOnLinux => true; public abstract IReadOnlyList Devices { get; } diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs index 59ce359..0686459 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs @@ -63,14 +63,14 @@ Task ExchangeReportsAsync( CancellationToken cancellationToken ); - Task ExchangeReportsAsync( + // Writes an output report and fetches the input report directly rather than waiting for a stream. + Task ExchangeGetInputReportAsync( string devicePath, byte[] request, Func isComplete, TimeSpan budget, - bool fallbackToGetInputReport, CancellationToken cancellationToken - ) => ExchangeReportsAsync(devicePath, request, isComplete, budget, cancellationToken); + ) => throw new NotSupportedException(); // Reads one feature report without writing anything first (unlike ExchangeAsync). Task GetFeatureAsync( @@ -320,20 +320,25 @@ public Task ExchangeReportsAsync( Func isComplete, TimeSpan budget, CancellationToken cancellationToken - ) => ExchangeReportsAsync(devicePath, request, isComplete, budget, fallbackToGetInputReport: false, cancellationToken); + ) => + BoundAsync( + $"HID report exchange on {devicePath}", + budget, + ct => ExchangeInputOutputReportsAsync(devicePath, request, isComplete, budget, ct), + cancellationToken + ); - public Task ExchangeReportsAsync( + public Task ExchangeGetInputReportAsync( string devicePath, byte[] request, Func isComplete, TimeSpan budget, - bool fallbackToGetInputReport, CancellationToken cancellationToken ) => BoundAsync( - $"HID report exchange on {devicePath}", + $"HID get-input-report exchange on {devicePath}", budget, - ct => ExchangeInputOutputReportsAsync(devicePath, request, isComplete, budget, ct, fallbackToGetInputReport), + ct => ExchangeGetInputReportsAsync(devicePath, request, isComplete, budget, ct), cancellationToken ); @@ -342,13 +347,12 @@ private async Task ExchangeInputOutputReportsAsync( byte[] request, Func isComplete, TimeSpan budget, - CancellationToken cancellationToken, - bool fallbackToGetInputReport = false + CancellationToken cancellationToken ) => await Task.Run( () => { - using var stream = OpenReportStream(devicePath, fallbackToGetInputReport); + using var stream = OpenReportStream(devicePath); cancellationToken.ThrowIfCancellationRequested(); // An empty request only listens, for a device that pushes its state on its own (Rapoo). @@ -360,12 +364,11 @@ await Task.Run( } var clock = Stopwatch.StartNew(); - var reportId = request.Length > 0 ? request[0] : (byte)0; while (clock.Elapsed < budget) { cancellationToken.ThrowIfCancellationRequested(); - if (stream.Read(ReportReadTimeout, reportId) is not { } input) + if (stream.Read(ReportReadTimeout) is not { } input) { continue; } @@ -389,7 +392,56 @@ await Task.Run( cancellationToken ); - private static IReportStream OpenReportStream(string devicePath, bool fallbackToGetInputReport = false) + private static async Task ExchangeGetInputReportsAsync( + string devicePath, + byte[] request, + Func 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 && isComplete(input)) + { + return 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()) { @@ -400,7 +452,7 @@ private static IReportStream OpenReportStream(string devicePath, bool fallbackTo var device = FindHidSharpDevice(devicePath) ?? throw new InvalidOperationException($"HID device {devicePath} is not present."); - return new HidSharpReportStream(device, fallbackToGetInputReport); + return new HidSharpReportStream(device); } // Off Windows one device carries every collection, so its longest output report is not the length of the @@ -570,14 +622,19 @@ internal interface IReportStream : IDisposable void Write(byte[] report); // Null when no report arrived within the timeout. - byte[]? Read(TimeSpan timeout, byte reportId = 0); + byte[]? Read(TimeSpan timeout); + + byte[]? GetInputReport(byte reportId); } -internal sealed class HidSharpReportStream(HidDevice device, bool fallbackToGetInputReport = false) : IReportStream +internal sealed class HidSharpReportStream(HidDevice device) : IReportStream { private readonly HidStream _stream = device.Open(); private readonly Lazy _nativeHandle = new(() => - OperatingSystem.IsWindows() && fallbackToGetInputReport ? NativeHid.TryOpen(device.DevicePath) : null + OperatingSystem.IsWindows() ? NativeHid.TryOpen(device.DevicePath) : null + ); + private readonly Lazy _linuxHandle = new(() => + OperatingSystem.IsLinux() ? LinuxHidraw.TryOpen(device.DevicePath) : null ); public int OutputReportLength(byte reportId) @@ -595,7 +652,7 @@ public int OutputReportLength(byte reportId) public void Write(byte[] report) => _stream.Write(report); - public byte[]? Read(TimeSpan timeout, byte reportId = 0) + public byte[]? Read(TimeSpan timeout) { _stream.ReadTimeout = (int)timeout.TotalMilliseconds; var input = new byte[device.GetMaxInputReportLength()]; @@ -606,17 +663,31 @@ public int OutputReportLength(byte reportId) } catch (TimeoutException) { - if (fallbackToGetInputReport && OperatingSystem.IsWindows() && _nativeHandle.Value is { } handle) + return null; + } + } + + 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)) { - input[0] = reportId; - if (NativeHid.TryGetInputReport(handle, input)) - { - return input; - } + return input; + } + } + else if (OperatingSystem.IsLinux() && _linuxHandle.Value is { } linuxHandle) + { + if (LinuxHidraw.TryGetInputReport(linuxHandle, input)) + { + return input; } - - return null; } + + return null; } public void Dispose() @@ -626,6 +697,11 @@ public void Dispose() { handle.Dispose(); } + + if (_linuxHandle.IsValueCreated && _linuxHandle.Value is { } linuxHandle) + { + linuxHandle.Dispose(); + } } } @@ -656,7 +732,23 @@ public int OutputReportLength(byte reportId) => public void Write(byte[] report) => _device.SetReport(MacHidReportType.Output, report); - public byte[]? Read(TimeSpan timeout, byte reportId = 0) => _device.ReadInputReport(timeout); + 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 (Exception) + { + return null; + } + } public void Dispose() => _device.Dispose(); } diff --git a/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs b/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs index 3fd238b..eb9978b 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs @@ -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. @@ -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) + { + return null; + } + } + [SupportedOSPlatform("linux")] internal static void SetFeature(SafeFileHandle handle, byte[] report) => Call(handle, SetFeatureNumber, report, "HIDIOCSFEATURE"); @@ -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 (Exception) + { + 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 diff --git a/src/DeviceBatteryInfo/Sources/Hid/MacHid.cs b/src/DeviceBatteryInfo/Sources/Hid/MacHid.cs index 5d9e7f0..bf3b612 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/MacHid.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/MacHid.cs @@ -18,6 +18,7 @@ internal sealed record MacHidEntry( internal enum MacHidReportType { + Input = 0, Output = 1, Feature = 2, } @@ -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) { diff --git a/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs b/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs index 7147c40..f4e9485 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/NativeHid.cs @@ -98,39 +98,9 @@ internal static SafeFileHandle Open(string devicePath) { try { - var handle = CreateFile( - devicePath, - GenericRead | GenericWrite, - FileShareRead | FileShareWrite, - securityAttributes: 0, - OpenExisting, - flagsAndAttributes: 0, - templateFile: 0 - ); - - if (handle.IsInvalid) - { - handle.Dispose(); - handle = CreateFile( - devicePath, - desiredAccess: 0, - FileShareRead | FileShareWrite, - securityAttributes: 0, - OpenExisting, - flagsAndAttributes: 0, - templateFile: 0 - ); - } - - if (handle.IsInvalid) - { - handle.Dispose(); - return null; - } - - return handle; + return Open(devicePath); } - catch (Exception) + catch (Win32Exception) { return null; } diff --git a/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs b/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs index a3cbce4..b2a9337 100644 --- a/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs @@ -65,7 +65,7 @@ internal sealed class RapooOfficeProtocol() "Rapoo", vendorId: 0x24AE, reportLength: FrameLength, - HidReportKind.InputOutput, + HidReportKind.GetInputReport, usagePage: 0xFF00, usage: 0x000E ) @@ -82,7 +82,7 @@ internal sealed class RapooOfficeProtocol() public override IReadOnlyList Devices { get; } = [new("MT760", 0x1870)]; - public override bool FallbackToGetInputReport => true; + public override bool SupportedOnLinux => false; public override async Task ReadAsync( HidChannel channel, diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index 785b1f7..7dcb8e3 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -219,7 +219,9 @@ public async Task Reads_the_raw_rapoo_office_battery_report() 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)) + .SelectMany(productId => + transport.FindCandidates(protocol.VendorId, productId, interfaceNumber: null, 0) + ) .Where(c => !c.CouldNotOpen && c.OutputReportLength >= protocol.ReportLength @@ -235,8 +237,7 @@ public async Task Reads_the_raw_rapoo_office_battery_report() transport, candidate.Path, candidate.ProductId, - protocol.ReportKind, - fallbackToGetInputReport: protocol.FallbackToGetInputReport + protocol.ReportKind ); try { @@ -249,17 +250,22 @@ public async Task Reads_the_raw_rapoo_office_battery_report() try { var reading = RapooOfficeProtocol.ParseBattery(report); - parsed = $"{reading.Percent?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"}% {reading.Status}"; + var percent = + reading.Percent?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"; + parsed = $"{percent}% {reading.Status}"; } catch (InvalidOperationException exception) { parsed = exception.Message; } - HardwareReport.Line( - subject, - $"{string.Join(" ", report.Take(16).Select(b => b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture)))} = {parsed}" + var hex = string.Join( + " ", + report.Take(16).Select(b => + b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture) + ) ); + HardwareReport.Line(subject, $"{hex} = {parsed}"); } catch (Exception exception) when (exception is not OutOfMemoryException) { diff --git a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs index fa67813..7ca86fc 100644 --- a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs @@ -321,6 +321,7 @@ private static string RepositoryFile(params string[] parts) .Assembly.GetTypes() .Where(t => t is { IsAbstract: false } && t.IsAssignableTo(typeof(HidProtocol))) .Select(t => (HidProtocol)Activator.CreateInstance(t, nonPublic: true)!) + .Where(p => p.SupportedOnLinux) .SelectMany(p => p.Devices.SelectMany(d => d.ProductIds.Select(id => (p.VendorId, id)))); // Without its line a model reads as not connected on Linux. From 214515b1b1ee2fa264d115b4a83978a76da5e670 Mon Sep 17 00:00:00 2001 From: Saksham Singh Date: Sun, 1 Nov 2026 18:12:22 +0530 Subject: [PATCH 4/4] refactor: keep cross-platform MT760 support and refine input report handling Address maintainer review feedback on PR #36: - Restore MT760 udev rule in packaging/linux/70-device-battery-info.rules and docs/linux-setup.md. - Drop SupportedOnLinux from HidProtocol and remove filter in LinuxUdevRuleTests. - Clear MT760 limitation note in README.md and update AGENTS.md. - Make IHidTransport.ExchangeGetInputReportAsync a regular interface member and implement it in all test fakes. - Add A_protocol_can_exchange_via_get_input_report unit test in HidProtocolTests. - Narrow exception catching in LinuxHidraw.TryOpen (IOException, UnauthorizedAccessException), LinuxHidraw.TryGetInputReport (IOException), and MacReportStream.GetInputReport (IOException). - Add debug logging in ExchangeGetInputReportsAsync for mismatched reports. --- AGENTS.md | 6 +- README.md | 2 +- docs/linux-setup.md | 2 + packaging/linux/70-device-battery-info.rules | 2 + .../Sources/Hid/HidProtocol.cs | 3 - .../Sources/Hid/HidTransport.cs | 20 ++++-- .../Sources/Hid/LinuxHidraw.cs | 4 +- .../Sources/Rapoo/RapooProtocol.cs | 2 - .../DeviceBatteryInfo.Tests/HidFamilyTests.cs | 8 +++ .../HidProtocolTests.cs | 72 ++++++++++++++++++- .../HidTransportPlatformTests.cs | 8 +++ .../LinuxSourceTests.cs | 1 - .../LogitechCenturionProtocolTests.cs | 8 +++ .../LogitechProtocolTests.cs | 8 +++ .../SonyProtocolTests.cs | 8 +++ 15 files changed, 136 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5b710e4..b72be5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -566,9 +566,9 @@ Design knowledge that is not obvious from the code alone: 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`) on Windows: - unlike the VT series, they require an active query report `BA A5 AA 00...`, answered by input - report `01 ...`. State 1 is on battery, 2 charging; level 1..100. `Sources/Aula/AulaProtocol.cs` + 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 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). diff --git a/README.md b/README.md index de89fde..e0700d8 100644 --- a/README.md +++ b/README.md @@ -117,7 +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 | Read on Windows only. | +| 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. | diff --git a/docs/linux-setup.md b/docs/linux-setup.md index 679c525..b123872 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -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"' \ diff --git a/packaging/linux/70-device-battery-info.rules b/packaging/linux/70-device-battery-info.rules index 6fe6471..b17c869 100644 --- a/packaging/linux/70-device-battery-info.rules +++ b/packaging/linux/70-device-battery-info.rules @@ -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" diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs b/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs index f5ec2d1..db4ae0e 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidProtocol.cs @@ -102,9 +102,6 @@ internal abstract class HidProtocol( // on its own schedule, slower than the default budgets. public virtual TimeSpan? ReadBudget => null; - // Override to false for a device that is read on Windows only. - public virtual bool SupportedOnLinux => true; - public abstract IReadOnlyList Devices { get; } // Send the request through the channel and parse the response. Throw when the device does not diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs index 0686459..01fbff3 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs @@ -70,7 +70,7 @@ Task ExchangeGetInputReportAsync( Func isComplete, TimeSpan budget, CancellationToken cancellationToken - ) => throw new NotSupportedException(); + ); // Reads one feature report without writing anything first (unlike ExchangeAsync). Task GetFeatureAsync( @@ -392,7 +392,7 @@ await Task.Run( cancellationToken ); - private static async Task ExchangeGetInputReportsAsync( + private async Task ExchangeGetInputReportsAsync( string devicePath, byte[] request, Func isComplete, @@ -425,9 +425,19 @@ await Task.Run( Thread.Sleep(SettleDelay(attempt)); - if (stream.GetInputReport(reportId) is { } input && isComplete(input)) + if (stream.GetInputReport(reportId) is { } input) { - return 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()); @@ -744,7 +754,7 @@ public int OutputReportLength(byte reportId) => _device.GetInputReport(input); return input; } - catch (Exception) + catch (IOException) { return null; } diff --git a/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs b/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs index eb9978b..49e7751 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs @@ -41,7 +41,7 @@ internal static SafeFileHandle Open(string devicePath) => { return Open(devicePath); } - catch (Exception) + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { return null; } @@ -67,7 +67,7 @@ internal static bool TryGetInputReport(SafeFileHandle handle, byte[] report) GetInput(handle, report); return true; } - catch (Exception) + catch (IOException) { return false; } diff --git a/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs b/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs index b2a9337..32dfa36 100644 --- a/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs @@ -82,8 +82,6 @@ internal sealed class RapooOfficeProtocol() public override IReadOnlyList Devices { get; } = [new("MT760", 0x1870)]; - public override bool SupportedOnLinux => false; - public override async Task ReadAsync( HidChannel channel, HidDeviceInfo device, diff --git a/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs b/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs index c629e6c..58bd0d2 100644 --- a/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs @@ -35,6 +35,14 @@ public Task ExchangeReportsAsync( CancellationToken cancellationToken ) => throw new NotSupportedException(); + public Task ExchangeGetInputReportAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + CancellationToken cancellationToken + ) => throw new NotSupportedException(); + public Task ExchangeAsync( string devicePath, byte[] request, diff --git a/tests/DeviceBatteryInfo.Tests/HidProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/HidProtocolTests.cs index 4343e3f..b815ce6 100644 --- a/tests/DeviceBatteryInfo.Tests/HidProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HidProtocolTests.cs @@ -35,7 +35,8 @@ private sealed class FakeTransport : IHidTransport 0x0001, 1, "Acme Air Mouse", - 8 + 8, + OutputReportLength: 8 ); public IReadOnlyList FindCandidates( @@ -55,6 +56,14 @@ public Task ExchangeReportsAsync( CancellationToken cancellationToken ) => throw new NotSupportedException(); + public Task ExchangeGetInputReportAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + CancellationToken cancellationToken + ) => Task.FromResult(new byte[] { 0x01, request[1], 88 }); + public Task ExchangeAsync( string devicePath, byte[] request, @@ -89,6 +98,51 @@ public async Task A_protocol_is_only_its_devices_and_one_read() Assert.That(reading.Percent, Is.EqualTo(77)); } + private sealed class AcmeOfficeProtocol() + : HidProtocol("Acme", vendorId: 0x1234, reportLength: 8, reportKind: HidReportKind.GetInputReport) + { + public override IReadOnlyList Devices { get; } = [new("Air Mouse", 0x0001)]; + + public override async Task ReadAsync( + HidChannel channel, + HidDeviceInfo device, + CancellationToken cancellationToken + ) + { + var response = await channel.ExchangeAsync( + [0x00, 0xB0], + r => r.Length > 2 && r[1] == 0xB0, + cancellationToken + ); + return BatteryReading.FromPercent(response[2]); + } + } + + [Test] + public async Task A_protocol_can_exchange_via_get_input_report() + { + var devices = new DeviceCatalog(); + devices.Set( + [ + new BatterySlot( + "air", + "Air", + BatterySourceKind.Mouse, + DeviceType.Catalog, + CatalogDeviceId: "acme-air-mouse" + ), + ] + ); + var family = new HidFamily([new AcmeOfficeProtocol()], new FakeTransport(), Serilog.Core.Logger.None); + + var sources = await new DeviceFamilyProvider([family], devices).DiscoverAsync( + CancellationToken.None + ); + + var reading = await sources.Single().ReadAsync(CancellationToken.None); + Assert.That(reading.Percent, Is.EqualTo(88)); + } + private sealed class DongleOrCableProtocol() : HidProtocol("Acme", vendorId: 0x1234, reportLength: 8) { @@ -136,6 +190,14 @@ public Task ExchangeReportsAsync( CancellationToken cancellationToken ) => throw new NotSupportedException(); + public Task ExchangeGetInputReportAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + CancellationToken cancellationToken + ) => throw new NotSupportedException(); + public Task ExchangeAsync( string devicePath, byte[] request, @@ -238,6 +300,14 @@ public Task ExchangeReportsAsync( CancellationToken cancellationToken ) => throw new NotSupportedException(); + public Task ExchangeGetInputReportAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + CancellationToken cancellationToken + ) => throw new NotSupportedException(); + public Task ExchangeAsync( string devicePath, byte[] request, diff --git a/tests/DeviceBatteryInfo.Tests/HidTransportPlatformTests.cs b/tests/DeviceBatteryInfo.Tests/HidTransportPlatformTests.cs index 9b02c57..8861353 100644 --- a/tests/DeviceBatteryInfo.Tests/HidTransportPlatformTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HidTransportPlatformTests.cs @@ -312,6 +312,14 @@ public Task ExchangeReportsAsync( TimeSpan budget, CancellationToken cancellationToken ) => throw new InvalidOperationException("no answer"); + + public Task ExchangeGetInputReportAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + CancellationToken cancellationToken + ) => throw new NotSupportedException(); } private static HidCandidate Headset(string path, params (int, int)[] usages) => diff --git a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs index 7ca86fc..fa67813 100644 --- a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs @@ -321,7 +321,6 @@ private static string RepositoryFile(params string[] parts) .Assembly.GetTypes() .Where(t => t is { IsAbstract: false } && t.IsAssignableTo(typeof(HidProtocol))) .Select(t => (HidProtocol)Activator.CreateInstance(t, nonPublic: true)!) - .Where(p => p.SupportedOnLinux) .SelectMany(p => p.Devices.SelectMany(d => d.ProductIds.Select(id => (p.VendorId, id)))); // Without its line a model reads as not connected on Linux. diff --git a/tests/DeviceBatteryInfo.Tests/LogitechCenturionProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/LogitechCenturionProtocolTests.cs index 9c4e3f6..b302508 100644 --- a/tests/DeviceBatteryInfo.Tests/LogitechCenturionProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LogitechCenturionProtocolTests.cs @@ -47,6 +47,14 @@ CancellationToken cancellationToken : Task.FromResult(reply); } + public Task ExchangeGetInputReportAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + CancellationToken cancellationToken + ) => throw new NotSupportedException(); + private IEnumerable Replies(byte[] payload) { yield return Frame(0x07, 0x01, 0x05); diff --git a/tests/DeviceBatteryInfo.Tests/LogitechProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/LogitechProtocolTests.cs index a0f6223..3f34739 100644 --- a/tests/DeviceBatteryInfo.Tests/LogitechProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LogitechProtocolTests.cs @@ -107,6 +107,14 @@ CancellationToken cancellationToken Used.Add(devicePath); return Task.FromResult(request[2] == 0x00 ? FeatureAnswer : StatusAnswer); } + + public Task ExchangeGetInputReportAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + CancellationToken cancellationToken + ) => throw new NotSupportedException(); } [Test] diff --git a/tests/DeviceBatteryInfo.Tests/SonyProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/SonyProtocolTests.cs index affea12..a356384 100644 --- a/tests/DeviceBatteryInfo.Tests/SonyProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/SonyProtocolTests.cs @@ -107,6 +107,14 @@ CancellationToken cancellationToken : throw new InvalidOperationException("No matching report within the budget."); } + public Task ExchangeGetInputReportAsync( + string devicePath, + byte[] request, + Func isComplete, + TimeSpan budget, + CancellationToken cancellationToken + ) => throw new NotSupportedException(); + public Task GetFeatureAsync( string devicePath, byte reportId,