Skip to content

Corsair Virtuoso XT/SE: add sidetone, lights and inactive time via the Bragi protocol - #570

Open
gdevenyi wants to merge 2 commits into
Sapd:masterfrom
gdevenyi:corsair-virtuoso-xt-bragi
Open

Corsair Virtuoso XT/SE: add sidetone, lights and inactive time via the Bragi protocol#570
gdevenyi wants to merge 2 commits into
Sapd:masterfrom
gdevenyi:corsair-virtuoso-xt-bragi

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Changes made

The Virtuoso XT/SE turns out to speak Corsair's Bragi property protocol — the same one corsair_void_v2w.hpp already uses — just framed on HID report 0x02 of the vendor collection (Usage-Page 0xff42) instead of an unnumbered report. Reworking the device around that protocol replaces the single battery command with the documented property table and adds three capabilities:

Capability How
CAP_SIDETONE property 0x46 (enable) + 0x47 (volume, 0–1000 in steps of 10)
CAP_INACTIVE_TIME property 0x0d (enable) + 0x0e (timeout in ms)
CAP_LIGHTS brightness property 0x02 — 0 or 1000

Packet framing[0] report ID 0x02, [1] target, [2] command (0x01 SET / 0x02 GET), [3] property, [5..] little-endian value. Replies come back on report 0x01 with the status in [3] (0x00 ok, 0x05 no such property, 0x09 write refused).

Software mode. Writes are refused with status 0x09 unless the headset is switched into software mode (property 0x03 = 2). Every write — lights included — is bracketed by an RAII guard that hands the headset straight back to hardware mode.

Lights follow ckb-next's updatergb_bragi(): brightness is the headset's own persisted setting and gates whatever effect it runs, so -l 0 writes 0 and -l 1 writes 1000. The user's own lighting effect comes back on -l 1, and off survives a power cycle. Setting a colour is a separate capability, in #572.

Wireless vs wired. A receiver relays commands to the headset paired with it (target 0x09); a wired headset answers for itself (target 0x08). Asking the wrong one is silent rather than an error. The registry shares one instance across product IDs and only records the ID it last matched on, so the product ID is used as a hint and then confirmed against the device — without that, a receiver and a wired headset plugged in at the same time break each other. (Plugging the USB-C cable into a Virtuoso XT doesn't just charge it; the headset re-enumerates as the wired product ID.)

Battery now reads BRAGI_BATTERY_LEVEL (0x0f, tenths of a percent) and reports charging from BRAGI_BATTERY_STATUS (0x10). The command 4.1.0 ships, { 0x02, 0x00 }, is a report ID and a target with no command or property, and the byte it prints is a constant 0x3b = 59 — which is why every report of this headset reads exactly 59%. Properties 0x11 and 0x12 decoding to exactly 0x1b1c and 0x0a62 confirm the property numbering matches ckb-next's Bragi table.

Testing

On a Virtuoso XT, in both wireless (0x0a64) and wired (0x0a62) modes:

  • battery level and charging state, cross-checked against the charge cable
  • sidetone across its range and off, inactive time set/disabled/restored, lights off/on — each verified by reading the property back, with mode confirmed back at hardware (1) after every write
  • lights confirmed visually: off stays off in hardware mode, on restores the headset's own effect rather than white
  • sidetone, sleep timer and brightness 0 all read back unchanged after a power cycle (confirmed as a real re-enumeration)
  • a receiver with its headset on the cable reports offline rather than a bogus reading

The SE product IDs (0x0a3e, 0x0a3d) are untested here — that's gated on #568's author running this branch on his SE.

Out of scope

iCUE's equalizer and spatial audio are host-side DSP applied to the PC audio stream rather than device settings, so they aren't reachable from a HID tool. Light colour is #572.

Checklist

  • I adjusted the README (if needed)
  • For new features in HeadsetControl: I discussed it beforehand in Issues or Discussions and adhered to the wiki

Existing capabilities on a device that already had an entry, so no new feature to discuss.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BHCVMmAAeV5WGP7oYZsk3T

Copilot AI lite review requested due to automatic review settings September 5, 2026 14:18
@gdevenyi
gdevenyi force-pushed the corsair-virtuoso-xt-bragi branch from 42c3e6a to 5542b39 Compare September 5, 2026 14:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The updated device header introduces at least one build-breaking include issue and a behavior in setLights(false) that can leave the device stuck in software mode with undesirable side effects.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR reworks the Corsair Virtuoso XT/SE device implementation to use Corsair’s Bragi property protocol on the vendor HID report, expanding support beyond battery reporting to additional device capabilities.

Changes:

  • Reimplemented Virtuoso XT/SE communication using Bragi property GET/SET framing and reply filtering.
  • Added support for CAP_SIDETONE, CAP_INACTIVE_TIME, and CAP_LIGHTS, including software-mode bracketing for persistent settings writes.
  • Updated README capability matrix and Corsair sidetone note to reflect XT/SE behavior.
File summaries
File Description
README.md Updates device capability table and Corsair sidetone guidance for Virtuoso XT/SE.
lib/devices/corsair_virtuoso_xt.hpp Implements Bragi protocol framing, target resolution, and adds sidetone/lights/inactive-time capabilities (plus battery read rework).
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/devices/corsair_virtuoso_xt.hpp Outdated
Comment thread lib/devices/corsair_virtuoso_xt.hpp Outdated
@gdevenyi
gdevenyi force-pushed the corsair-virtuoso-xt-bragi branch 2 times, most recently from 5f23a5c to 01796de Compare September 5, 2026 14:27
@gdevenyi

gdevenyi commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Opened #572 in Ideas to discuss RGB colour support, since the Virtuoso XT's lighting hardware does full RGB (I cycled the zones red/green/blue while reverse-engineering it) but no capability in the project can express a colour. This PR stays as-is — static white for -l 1 — until there's a shape agreed there.

The Virtuoso XT/SE speaks Corsair's Bragi protocol, the same one
corsair_void_v2w.hpp uses, but framed on HID report 0x02 of the vendor
collection instead of an unnumbered report. Reworking the device around that
protocol replaces the single undocumented battery command with the documented
property table and adds three capabilities:

  * CAP_SIDETONE      - property 0x46 (enable) and 0x47 (volume, 0-1000)
  * CAP_INACTIVE_TIME - property 0x0d (enable) and 0x0e (timeout in ms)
  * CAP_LIGHTS        - lighting handle 0x00, resource 0x01

Battery now reads BRAGI_BATTERY_LEVEL (0x0f, tenths of a percent) and reports
charging from BRAGI_BATTERY_STATUS (0x10). The previous command returned a byte
that stayed frozen at one value across a full charge cycle, while 0x0f tracked
the level and matched the charge state; properties 0x11 and 0x12 decoding to
the exact vendor and product IDs confirm the property numbering.

Writes are refused with status 0x09 unless the headset is switched into
software mode (property 0x03 = 2). Settings written that way persist, so those
writes are bracketed by a scope guard that hands the headset straight back to
hardware mode rather than parking it in software mode.

Lighting is the exception and deliberately stays in software mode. It is a
block of data pushed through the open/write/close handle sequence, and hardware
mode drives the LEDs from the effect the headset runs itself, which paints
straight over the frame we wrote - restoring it makes "lights off" do nothing
at all. The headset drops back to hardware mode by itself within a few minutes
of the host going quiet, and the frame it was last given survives that.

Because the capability is only on/off, "on" paints every zone static white
rather than restoring the effect that was running before; the protocol offers
no way to read that effect back and replay it.

A wireless receiver relays commands to the headset paired with it (target 0x09)
while a wired headset answers for itself (target 0x08). The registry shares one
instance across product IDs and only records the ID it last matched on, so the
target is confirmed against the device rather than trusted - otherwise a
receiver and a wired headset plugged in together break each other.

Tested on a Virtuoso XT in both wireless (0x0a64) and wired (0x0a62) modes:
battery level and charging state, sidetone across its range and off, inactive
time set/disabled/restored - each verified by reading the property back - and
lights toggled both ways through the CLI and confirmed visually. The SE product
IDs are untested and ride along on the same framing.

Note that iCUE's equalizer and spatial audio are host-side DSP applied to the
PC audio stream, not device settings, so they are out of scope here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHCVMmAAeV5WGP7oYZsk3T

@Sapd Sapd left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Taking this one. The battery rework fixes a real bug. What 4.1.0 ships is

{ 0x02, 0x00 }
  • report ID and target, no command, no property. The byte we print is 0x3b = 59, which is why #567's reporter and the author of #568 both got exactly 59%. 0x0f is what corsair_void_v2w.hpp reads too.

#568 is the same protocol. Closed it and asked its author to run this branch on his SE - that's the gate for the SE PIDs. CI is green.

Blocking:

  1. PROP_BRIGHTNESS = 1000 is written unconditionally (line 238), before the on/off branch. -l 0 sets brightness to max and then paints black. On Corsair's other Bragi devices 0x02 is the persisted hardware-mode brightness, so this likely also overwrites whatever the user set in iCUE.

    Compare ckb-next led_bragi.c updatergb_bragi(): it writes 0x02 = 0 when the frame is all black and 1000 otherwise - "being able to just switch them off even in hw mode is really nice". I think that's the whole feature for a boolean: -l 0 = software mode, brightness 0, guard back to hardware mode; -l 1 = brightness 1000. Off survives a power cycle, on brings back the user's own effect instead of static white, and the headset is never left in software mode. Please test that on the XT. If it holds, drop writeLighting() from this PR - it comes back with #572.

  2. Header (46-49) and the setLights comment state the idle drop-back as fact. Measured on one XT - say so.

Minor:

  • resolveTarget() reads 0x0f and getBattery() reads it again. Return the probed value.
  • 300 ms probe: a headset slower than that is reported offline, and its late reply is the first thing the next readReply() sees - there's no property echo to catch it. Worth a comment.
  • writeLighting(): ckb-next closes and reopens on open status 0x03. Only relevant if the frame path stays.
  • Sidetone: checked it survives a power cycle, or only the mode switch?

Replied on #572.

Review feedback on Sapd#570.

setLights() wrote brightness 1000 unconditionally and then painted a static
white or black frame, which left the headset in software mode and replaced the
user's own lighting effect with white. It now does what ckb-next's
updatergb_bragi() does for Corsair's other Bragi devices: switch to software
mode, write brightness 0x02 as 0 or 1000, and hand the headset back to hardware
mode through the same scope guard as every other write.

Checked on the XT: -l 0 turns the LEDs off and they stay off in hardware mode,
-l 1 brings back the headset's own effect rather than white, and brightness 0
survives a power cycle. writeLighting() and its handle constants are gone from
this PR; they come back with light colour support (Sapd#572).

The last session's evidence against this approach was invalid. Two of the three
runs crashed before writing anything, and the one that did never entered
software mode, so its writes were refused.

Also from review:

- resolveTarget() already reads the battery level to find a target that
  answers, so it now returns that value and getBattery() no longer asks twice.
- Document the 300 ms probe timeout's sharp edge: a slower headset is reported
  offline, and because replies carry no property ID its late reply can be taken
  by the next read for the same target and command.
- State what was measured, and on what. Sidetone, the sleep timer and
  brightness all survived a power cycle on the one XT tested; the drop back to
  hardware mode after a few idle minutes was seen on that unit only, and
  nothing relies on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHCVMmAAeV5WGP7oYZsk3T
@gdevenyi

Copy link
Copy Markdown
Contributor Author

Thanks — all addressed in 9f54a74, added as a new commit rather than a force-push so the delta is reviewable.

1. Brightness. Your design holds on the XT. -l is now software mode → brightness 0 or 1000 → guard back to hardware mode, and writeLighting() is gone from this PR. Tested visually and by readback, over both wired and wireless:

  • -l 0: LEDs go off and stay off in hardware mode; mode reads back 1.
  • -l 1: LEDs come back showing the headset's own effect, not white. I checked this straight after a power cycle, so nothing left over from the old frame implementation could be showing.
  • Brightness 0 survives a power cycle — lights still off afterwards, and 0x02 reads back 0.

I should own why I didn't land on this myself: last session I had "evidence" that brightness doesn't gate the LEDs in hardware mode. Going back through it, two of the three runs crashed before writing anything (the hidraw node had moved when the headset changed transport), and the one that did run never entered software mode, so every write was refused. My probe also wasn't matching the command echo, which is why it "restored" brightness to 35500. It was worthless, and your reading of updatergb_bragi() was right.

2. Measured on one XT. The header no longer mentions the idle drop-back at all — nothing depends on it now that lights restore hardware mode. The guard's comment says it was seen on one unit and not relied on, and the power-cycle persistence is attributed the same way.

Minor:

  • resolveTarget() now returns the level it probed; getBattery() doesn't read 0x0f twice.
  • 300 ms probe: commented on both halves — a slower headset is reported offline, and since replies carry no property ID, its late reply can be taken by the next read for the same target and command.
  • writeLighting() close/reopen on open status 0x03: moves with the frame path into the RGB colour support: no capability can express a colour today #572 PR, and I'll implement it there.
  • Sidetone survives a power cycle. I set sidetone 780/1000, sleep 20 min and brightness 0, power-cycled (USB device number changed, so it genuinely re-enumerated), and all three read back unchanged. Restored afterwards.

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.

3 participants