Skip to content

zfeat: add Corsair, Rapoo, AULA and Sony HID battery backends - #6

Merged
PyFlat-JR merged 2 commits into
PyFlat:mainfrom
pedrohti:add-new-devices
Oct 3, 2026
Merged

PyFlat-JR merged 2 commits into
PyFlat:mainfrom
pedrohti:add-new-devices

Conversation

@pedrohti

@pedrohti pedrohti commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds four HID battery backends under "Other devices", plus three small changes to the shared HID plumbing that two of them need.

New devices

  • Corsair VOID PRO Wireless (1B1C:0A75, Sources/Corsair/): output report C9 64 on FFC5:0001; level in byte 2 (the mic-up bit 0x80 is masked off), state in byte 4. A headset that is off reports state 0 and is no reading, not 0%.
  • Rapoo VT3 PRO (24AE:1215 dongle, 24AE:4415 cable, Sources/Rapoo/): the mouse pushes a status report on FF00:0002 by itself, so the read only listens. Byte 5 is the state (1 battery, 2 charging), byte 6 the level. While charging the firmware sends 0x7F, which reads as Charging with no percent. Charging at 100 reads as Full.
  • AULA F75 on its Compx 2.4G receiver (3554:FA09, Sources/Aula/): command 0x4A on FF02:0002, 20-byte frames with a byte-sum checksum; level in byte 5, state in byte 6. With the cable in, the level is pinned at 100 (a 97% keyboard reads 100), so it reads as Charging with no percent.
  • Sony DualSense (054C:0CE6, Sources/Sony/): listens to the input report. The status byte is byte 53 over USB and byte 54 over Bluetooth (0x31). A fresh Bluetooth link sends only the short 0x01 report with no battery, so feature 0x05 is read once to switch it to 0x31 until it disconnects.

Shared HID changes

  • HidSharpTransport.ExchangeReportsAsync only listens when the request is empty (Rapoo, DualSense).
  • HidProtocol.ReadBudget (virtual, default null) lets a protocol extend the read and probe budgets. Rapoo pushes every ~3.2 s, longer than the 400 ms probe and the 2 s read. HidFamily passes it through.
  • IHidTransport.GetFeatureAsync / HidChannel.GetFeatureAsync read one feature report without writing one first. ExchangeAsync always does SetFeature before GetFeature, and writing 0x05 to the DualSense is something no reference implementation does. The interface method has a default implementation, so the existing fakes are untouched.

Known limits (documented in README / AGENTS.md)

  • Rapoo on the cable pushes irregularly (3 s bursts, then 30-60 s of silence while in use), so the reading may go stale there. Over the dongle it is a steady 3.2 s. The proper fix would be a background listener caching the last report per path; I left it out to keep this PR small.
  • AULA F75 in wired mode enumerates as a separate Sinowealth device (258A:010C) with an unknown protocol and is not read.
  • The DualSense reports its level in 10% steps. The DualSense Edge (0DF2) is not included (not tested).
  • The 8BitDo Ultimate Wireless for PC (2DC8:3106) is deliberately not supported: its dongle presents as a wired XInput pad (WIRED/FULL) and has no vendor collection.

Tests

  • Per-brand parser tests built from frames captured on the real devices: CorsairProtocolTests, RapooProtocolTests, AulaProtocolTests, SonyProtocolTests.
  • Fake-transport tests: the DualSense Bluetooth switch (short report → feature 0x05 → full report), and ReadBudget reaching both the probe and the reads (HidProtocolTests).
  • The four protocols added to HardwareTests.Protocols.

Docs: README "Supported devices" table, AGENTS.md (orientation + one section per protocol), docs/adding-a-device.md (listen-only reads, ReadBudget, GetFeatureAsync, per-brand test files). Version bumped to 1.4.1.

Checklist

  • [/] I have read AGENTS.md and CONTRIBUTING.md. (partialy)
  • dotnet build and dotnet test pass locally (0 warnings, 146 tests).
  • I updated README.md / AGENTS.md if this changes what they describe.
  • If this touches a device backend: I tested it against the real hardware (see below).

Device/hardware tested against (if applicable)

Windows 11 Pro 10.0.26100. All four were read through the plugin with HardwareTests, and the raw frames were captured to build the tests.

Device Connection States verified on hardware
Corsair VOID PRO Wireless 0A75 USB dongle discharging, charging, full (state 4, level still 96), off (state 0), mic up/down bit
Rapoo VT3 PRO 1215 / 4415 dongle, cable on battery (incl. 100%), charging with level, charging 0x7F after a firmware update, full on cable
AULA F75 3554:FA09 2.4G receiver, receiver + cable, wired mode on battery (97%, 100%), cable in (pinned 100), wired mode not answering
Sony DualSense 0CE6 USB, Bluetooth USB charging, Bluetooth discharging; short → full report switch after feature 0x05, verified twice by reconnecting the controller

Not verified: Rapoo / DualSense low-battery states, and the DualSense's error charge states (handled as no reading).

AI assistance

A significant part of this PR was written with Claude Code: the protocol code, the tests and the docs. The protocols for Corsair and AULA come from public sources (HeadsetControl, a Node tray app, battery-hub, womier-l65-linux). Corsair Void, Rapoo's layout and the AULA cable / DualSense Bluetooth behaviour were mapped from frames captured on my own devices. Every reading above was run on the real hardware, and I reviewed the diff.

@PyFlat-JR PyFlat-JR left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for this, it's a really solid PR! The protocols are documented carefully, the frames were captured on real hardware, an off device counts as "no reading" instead of 0%, and the docs and tests were updated along with the code. 🙏

The merge of main (23ca5ff) dropped two things from #5 (macOS support), and one of them stops the build:

  1. The tests don't build. HardwareTests.cs lost using DeviceBatteryInfo.Sources.SystemBattery;, so ISystemPowerReader, MacSystemPowerReader and WindowsSystemPowerReader no longer resolve (3 errors). With that line back, the PR merged into main builds and all tests pass.
  2. ExchangeReportsAsync went back to GetMaxOutputReportLength() and dropped OutputReportLength(device, request), which breaks report-based devices on macOS (details inline).

Also, please make GetFeatureAsync go through _openFeatureChannel (inline) so it works on macOS too.

using DeviceBatteryInfo.Sources.Rapoo;
using DeviceBatteryInfo.Sources.Razer;
using DeviceBatteryInfo.Sources.SystemBattery;
using DeviceBatteryInfo.Sources.Sony;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The merge removed using DeviceBatteryInfo.Sources.SystemBattery; (it was added in #5). HardwareTests still uses ISystemPowerReader, MacSystemPowerReader and WindowsSystemPowerReader, so the test project no longer builds. Please add it back next to the other usings.

// An empty request only listens, for a device that pushes its state on its own (Rapoo).
if (request.Length > 0)
{
var output = new byte[Math.Max(device.GetMaxOutputReportLength(), request.Length)];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reverts a fix from #5. Off Windows, one HidSharp device carries every collection, so GetMaxOutputReportLength() is the longest report on the whole device, not the length of the report being sent. The device then ignores the padded report (see the comment on OutputReportLength below). Keeping the listen-only branch and using the helper again should cover both:

Suggested change
var output = new byte[Math.Max(device.GetMaxOutputReportLength(), request.Length)];
var output = new byte[Math.Max(OutputReportLength(device, request), request.Length)];

Comment on lines +242 to +263
public Task<byte[]> GetFeatureAsync(
string devicePath,
byte reportId,
CancellationToken cancellationToken
)
{
if (!OperatingSystem.IsWindows())
{
throw new PlatformNotSupportedException("HID feature reports are Windows-only in v1.");
}

var device =
DeviceList
.Local.GetHidDevices()
.FirstOrDefault(d =>
string.Equals(d.DevicePath, devicePath, StringComparison.OrdinalIgnoreCase)
) ?? throw new InvalidOperationException($"HID device {devicePath} is not present.");

cancellationToken.ThrowIfCancellationRequested();
var report = new byte[device.GetMaxFeatureReportLength()];
report[0] = reportId;
using var handle = NativeHid.Open(devicePath);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since #5, the transport opens feature reports through _openFeatureChannel: NativeFeatureChannel on Windows, HidSharpFeatureChannel elsewhere. Going through it here instead of NativeHid.Open would drop the "Windows-only in v1" exception, so the DualSense's Bluetooth switch also works on macOS. It would also let tests inject a fake channel. Something like:

var report = new byte[device.GetMaxFeatureReportLength()];
report[0] = reportId;
using var channel = _openFeatureChannel(devicePath);
channel.Get(report);
return Task.FromResult(report);

(I haven't tested the DualSense on macOS, so if HidSharp's feature path doesn't work there, a comment saying so is fine too.)

@pedrohti

pedrohti commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Thank you! 🙌🏻
I will revise and fix it.

Add four HID device backends, each read on real hardware:

- Corsair VOID PRO Wireless (1B1C:0A75): output report C9 64 on
  FFC5:0001, level in byte 2 (mic-up bit 0x80 masked), state in byte 4.
- Rapoo VT3 PRO (24AE:1215 dongle, 4415 cable): listens to the status
  report the mouse pushes on FF00:0002; level 0x7F while charging reads
  as charging with no percent, charging at 100 reads as full.
- AULA F75 on the Compx receiver (3554:FA09): command 0x4A on FF02:0002
  with a byte-sum checksum; on the cable the level is pinned at 100, so
  it reads as charging with no percent. Wired mode (258A:010C) is not read.
- Sony DualSense (054C:0CE6) over USB and Bluetooth: listens to the
  input report; a fresh Bluetooth link only sends the short report, so
  feature 0x05 is read once to switch it to the full 0x31 report.

Shared HID changes needed by these devices:

- ExchangeReportsAsync only listens when the request is empty.
- HidProtocol.ReadBudget lets a protocol extend the read and probe
  budgets (Rapoo pushes every ~3.2 s, longer than the 400 ms probe).
- IHidTransport.GetFeatureAsync reads a feature report without writing
  one first, unlike the feature-report ExchangeAsync.

Known limits: on the cable the Rapoo pushes irregularly and its reading
may go stale; the DualSense level comes in 10% steps. The 8BitDo
Ultimate (2DC8:3106) is not supported: its dongle presents as a wired
XInput pad and exposes no battery.

Tests: per-brand parser tests built from captured frames, a fake-transport
test for the DualSense Bluetooth switch and for ReadBudget, and the new
protocols added to the hardware tests. README, AGENTS.md and
docs/adding-a-device.md updated. Bump version to 1.4.1.
@pedrohti

pedrohti commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor Author

I updated my branch with the latest version of the main branch and fixed the conflict. Could you review it again?

And could a Mac user test it as well, just to be sure? xD

(I confess I don't know if I did it right, but it seems to have worked out well)

@PyFlat-JR
PyFlat-JR merged commit 6f460c9 into PyFlat:main Oct 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants