diff --git a/AGENTS.md b/AGENTS.md index 176d6fb..b72be5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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 dc814b9..e0700d8 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 | 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 097e08e..db4ae0e 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( @@ -41,21 +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, cancellationToken - ); + ), + _ => throw new NotSupportedException($"Unsupported HID report kind: {kind}"), + }; public Task GetFeatureAsync(byte reportId, CancellationToken cancellationToken) => transport.GetFeatureAsync(DevicePath, reportId, budget ?? ReadBudget, cancellationToken); diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs index 5ba1be1..01fbff3 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs @@ -63,6 +63,15 @@ Task ExchangeReportsAsync( CancellationToken cancellationToken ); + // 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, + CancellationToken cancellationToken + ); + // Reads one feature report without writing anything first (unlike ExchangeAsync). Task GetFeatureAsync( string devicePath, @@ -319,6 +328,20 @@ CancellationToken cancellationToken cancellationToken ); + public Task ExchangeGetInputReportAsync( + string devicePath, + byte[] request, + Func 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 ExchangeInputOutputReportsAsync( string devicePath, byte[] request, @@ -369,6 +392,65 @@ await Task.Run( cancellationToken ); + private 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) + { + 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()) @@ -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 _nativeHandle = new(() => + OperatingSystem.IsWindows() ? NativeHid.TryOpen(device.DevicePath) : null + ); + private readonly Lazy _linuxHandle = new(() => + OperatingSystem.IsLinux() ? LinuxHidraw.TryOpen(device.DevicePath) : null + ); public int OutputReportLength(byte reportId) { @@ -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")] @@ -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(); } diff --git a/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs b/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs index 3fd238b..49e7751 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 exception) when (exception is IOException or UnauthorizedAccessException) + { + 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 (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 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 9414587..f4e9485 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( @@ -86,6 +94,18 @@ internal static SafeFileHandle Open(string devicePath) return handle; } + internal static SafeFileHandle? TryOpen(string devicePath) + { + try + { + return Open(devicePath); + } + catch (Win32Exception) + { + return null; + } + } + internal static void SetFeature(SafeFileHandle device, byte[] report) { if (!HidD_SetFeature(device, report, (uint)report.Length)) @@ -101,4 +121,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..32dfa36 100644 --- a/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs +++ b/src/DeviceBatteryInfo/Sources/Rapoo/RapooProtocol.cs @@ -58,3 +58,69 @@ 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.GetInputReport, + 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)]; + + 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) => + report.Length >= 3 && report[0] == StatusSuccess && report[2] is > 0 and <= 100; + + internal static BatteryReading ParseBattery(ReadOnlySpan report) + { + var status = report[0]; + if (status != StatusSuccess) + { + throw new InvalidOperationException($"The mouse reported non-success status 0x{status:X2}."); + } + + var state = report[1]; + var level = report[2]; + + if (level is 0 or > 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..7dcb8e3 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(), @@ -211,6 +212,68 @@ 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 + ); + try + { + var report = await channel.ExchangeAsync( + RapooOfficeProtocol.BuildBatteryRequest(), + RapooOfficeProtocol.IsBatteryReply, + CancellationToken.None + ); + string parsed; + try + { + var reading = RapooOfficeProtocol.ParseBattery(report); + var percent = + reading.Percent?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"; + parsed = $"{percent}% {reading.Status}"; + } + catch (InvalidOperationException exception) + { + parsed = exception.Message; + } + + 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) + { + 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/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/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/RapooProtocolTests.cs b/tests/DeviceBatteryInfo.Tests/RapooProtocolTests.cs index c2faa11..44c680a 100644 --- a/tests/DeviceBatteryInfo.Tests/RapooProtocolTests.cs +++ b/tests/DeviceBatteryInfo.Tests/RapooProtocolTests.cs @@ -45,4 +45,51 @@ 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_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([0x01, 0x00]), Is.False); + } + } } 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,