Conversation
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
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
As agreed in Sapd#572. CAP_LIGHTS can only switch lights on and off, and no capability could express a color. Shape: - CAP_LIGHT_COLOR is appended at the end of CAPABILITIES_XLIST, with its descriptor at the end of CAPABILITY_DESCRIPTORS. min/max are nullopt and the value hint is <RRGGBB>. - The parameter is a LightColorSettings { r, g, b } in the FeatureParam variant, like the equalizer. int stays reserved for scalars in a range. - HIDDevice::setLightColor() takes const LightColorSettings&, so an optional zone can be added later without touching the signature. It applies to every zone; the zone count stays private to the device. 000000 is a valid color. - C++: Headset::setLightColor(). C: hsc_set_light_color(h, r, g, b), scalars so the ABI doesn't move when zones arrive. - CLI: --light-color RRGGBB, a leading # accepted. The request runs after -l, so with both on one command line the color wins. Documented in --help, the README and at the request table. - Output: actions gain color "#rrggbb" in JSON, YAML and ENV, and value packs 0xRRGGBB. Black packs to 0, and the `value > 0` guard - duplicated in ActionData::serialize and inline in the YAML and ENV writers - would have dropped it. The rule now lives in ActionData::hasValue() for all three. It keeps omitting 0 for other actions, because the equalizer returns 0 to mean "no value" and printing it would be wrong. hsc_capability_t mirrors the capability enum by value and nothing checked that they agree, which is how Sapd#515 broke the C ABI. headsetcontrol_c.cpp now static_asserts every entry against CAPABILITIES_XLIST as well as the count, so a missing, reordered or unmirrored capability fails the build and names the entry. Checked by deleting HSC_CAP_NOISE_FILTER (the Sapd#515 shape) and HSC_CAP_LIGHT_COLOR from the header: both fail with a message naming the entry. The test device implements it. Tests cover the hex parser, ActionData's value rule, the C and C++ API through the test device, and CLI output in every format - including black and -l ordering. Reverting hasValue() to value > 0 fails the ActionData test, and doing so only in the YAML writer fails the CLI test. Docs: ADDING_A_CAPABILITY.md now says capabilities are append-only and must be mirrored in the C header, and states the int-vs-struct rule. LIBRARY_USAGE.md, ADDING_A_DEVICE.md and AGENTS.md list the new capability, plus the two their lists had already missed (noise filter, sidetone status). README table regenerated with --readme-helper; the only change is the new column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHCVMmAAeV5WGP7oYZsk3T
The frame path removed from Sapd#570 comes back here, as agreed there. The color is painted through handle 0 / resource 1 as a planar frame for three zones (every red byte, then green, then blue). Opening a handle that is already open returns status 0x03 on the XT - checked - so, as ckb-next does, the handle is closed and reopened once. Tested by leaving the lighting handle open and running --light-color: it succeeds and leaves the handle closed. The handle is also closed when the frame write fails, so one bad run does not block the next. Two things set a color apart from the other writes, both checked on the XT: - Brightness gates a painted frame as well as the headset's own effect. With brightness 0 a green frame stayed dark; raising brightness showed it. A color therefore raises brightness, which is also what "color implies on" asks for: `-l 0 --light-color 00ff00` in one call ends up green. - Returning to hardware mode replaces the frame with the headset's own effect at once, so a color write stays in software mode instead of using the guard. That makes -l 1 the way back: after a color it restores the user's effect. The color is temporary on this headset. With nothing talking to it, the headset drops back to hardware mode on its own and its own effect returns: it was still in software mode 45 s after --light-color and back in hardware mode by 90 s. Vendor software gets a lasting color by staying connected. I looked for a way to store a hardware-mode color and did not find a safe one. A read-only sweep of every 16-bit property and resource ID found 33 properties and 6 resources, all below 0x100. The only plausible stores are resources 0x14 (6 bytes) and 0x2a (5 bytes), and both refuse reads, so their contents cannot be backed up; nothing writes to them. The limitation is documented in the device header and in a README note. Black (000000) is a valid color and turns the zones dark. README table regenerated; the Virtuoso gains the light color column. Tested through the CLI and confirmed visually, wired (0x0a62): green after -l 0 in the same call, black goes dark, -l 1 restores the headset's own effect. Wireless (0x0a64, target 0x09): magenta. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHCVMmAAeV5WGP7oYZsk3T
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved moderate findings remain in API versioning and Virtuoso error and connection handling.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds CAP_LIGHT_COLOR with CLI, C/C++ APIs, serialization, documentation, tests, and Corsair Virtuoso XT/SE support.
Changes:
- Adds RGB parsing, capability plumbing, and public APIs.
- Implements color output across JSON, YAML, and ENV.
- Adds Corsair lighting control and test coverage.
Unresolved findings concern API versioning and Virtuoso error, cleanup, connectivity, and target-resolution handling.
File summaries
| File | Summary |
|---|---|
tests/test_utilities.cpp |
Tests RGB parsing |
tests/test_output_formats.cpp |
Tests color and value serialization |
tests/test_library_api.cpp |
Tests C/C++ APIs |
tests/test_cli_output.cpp |
Tests CLI output and ordering |
README.md |
Documents color support and device behavior |
lib/utility.hpp |
Declares the color parser |
lib/utility.cpp |
Implements color parsing |
lib/result_types.hpp |
Adds light color result types |
lib/headsetcontrol.hpp |
Adds the C++ color API |
lib/headsetcontrol.cpp |
Implements the C++ color API |
lib/headsetcontrol_c.h |
Adds the C API and capability enum |
lib/headsetcontrol_c.cpp |
Implements the C API and enum checks |
lib/feature_handlers.hpp |
Registers color handling |
lib/devices/hid_device.hpp |
Adds the device color method |
lib/devices/headsetcontrol_test.hpp |
Adds test-device support |
lib/devices/corsair_virtuoso_xt.hpp |
Implements Virtuoso lighting control |
lib/device.hpp |
Adds capability and RGB parameter plumbing |
lib/capability_descriptors.hpp |
Adds capability metadata |
docs/LIBRARY_USAGE.md |
Documents library APIs |
docs/ADDING_A_DEVICE.md |
Updates capability guidance |
docs/ADDING_A_CAPABILITY.md |
Documents capability implementation rules |
cli/output/output.cpp |
Emits structured color fields |
cli/output/output_data.hpp |
Adds color-aware value handling |
cli/main.cpp |
Parses and schedules color actions |
AGENTS.md |
Updates capability guidance |
Review details
Suppressed comments (3)
cli/output/output.cpp:103
- Adding
action.colorchanges the structured output schema, butAPI_VERSIONremains1.5(line 29).docs/LIBRARY_USAGE.md:97-104defines minor-version increments for additions, so clients cannot distinguish this schema from the old 1.5 format; bump the version and update the corresponding documentation/fixtures.
action.color = std::format("#{:02x}{:02x}{:02x}", c.r, c.g, c.b);
lib/devices/corsair_virtuoso_xt.hpp:133
- When the headset is charging, this new branch returns
BATTERY_CHARGING, but the CLI's--connectedimplementation incli/main.cpp:1209-1210only treatsBATTERY_AVAILABLEas connected. Consequentlyheadsetcontrol --connectedreportsfalsefor a connected Virtuoso while it is charging; the connectivity check should accept both available and charging statuses.
auto status = BATTERY_AVAILABLE;
if (auto charge_state = readProperty(device_handle, resolved->target, PROP_BATTERY_STATUS);
charge_state && *charge_state == CHARGE_STATE_CHARGING) {
status = BATTERY_CHARGING;
lib/devices/corsair_virtuoso_xt.hpp:453
resolveTargettreats every failedreadPropertyas evidence that the candidate was wrong and discards the error. If both probes hit an HID, permission, or protocol failure, the caller is incorrectly told that the headset is offline; only a silent timeout/offline result should trigger the alternate probe, while other errors should be propagated.
for (const uint8_t candidate : { hinted, alternate }) {
if (auto level = readProperty(
device_handle, candidate, PROP_BATTERY_LEVEL, TARGET_PROBE_TIMEOUT_MS)) {
return ResolvedTarget { .target = candidate, .battery_level = *level };
}
- Files reviewed: 25/25 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.
| if (auto result = writeProperty(device_handle, target, PROP_BRIGHTNESS, BRIGHTNESS_MAX); | ||
| !result) { | ||
| return result.error(); |
There was a problem hiding this comment.
Fixed in d79a316. SoftwareModeGuard gained dismiss(), and the color path now takes a guard like every other write, dismissing it only once the frame is written. A successful color stays in software mode, so the frame stays visible; a failed brightness or frame write hands the headset back to hardware mode.
test_corsair_virtuoso.cpp covers both halves with a mock: a clean write takes six requests and never restores hardware mode, and a failed frame write still closes the handle and then restores it. Dropping the guard fails the failure case, and dropping dismiss() fails the success case. On the XT, --light-color still leaves the headset in software mode.
| if (result.error().code == DeviceError::Code::Timeout) { | ||
| return DeviceError::deviceOffline("Headset not connected or powered off"); | ||
| } | ||
| return result.error(); |
There was a problem hiding this comment.
Fixed — in #570 (8feff12), since that's where this code comes from, merged in here. The previous review there settled for a comment; this replaces the comment with a fix.
Every request now goes through transact(), which throws away anything already queued (non-blocking reads, bounded) before writing. Nothing read before a request goes out can be its reply, so it only ever discards stale replies and the volume events readReply() skipped anyway. The handle commands for the lighting frame use it too. A battery read still takes about 0.2 s on the XT.
The test mock makes each report readable only after a given number of writes, which is what distinguishes a stale reply from a real one. With a stale charge-state reply queued before getBattery(), the level comes out right; without the discard, the stale 1 is read as the battery level and the test fails.
Two findings from Copilot's review of the stacked Sapd#580, both in code this PR introduces. A reply that arrives after its request timed out stayed queued, and replies carry no property or handle ID - only who sent them and the command they answer - so the next request of the same kind would take it for its own. The last review settled for a comment on this; it is fixed instead. Every request now goes through transact(), which throws away anything already queued before writing. Reports read before a request is sent cannot be its reply, so this only ever drops stale replies and the volume events readReply() skipped anyway. It is bounded, so a device that never stops sending cannot stall a request, and a battery read still takes about 0.2 s. resolveTarget() treated every failed probe as "wrong target" and ended with "offline", so a failed HID write or an unexpected status was reported as the headset being powered off. Only two failures now mean wrong target: silence, and "no such property", which is a receiver answering for itself with no headset behind it. Anything else is returned as is. tests/test_corsair_virtuoso.cpp adds a mock whose reports only become readable after a given number of writes, which is what distinguishes a stale reply from a real one. It covers battery over both targets, a stale reply queued before the request, a receiver with no headset (offline, not unsupported), and a HID write failure (propagated, and the other target is not probed). Removing the discard fails the stale-reply test; treating every probe failure as wrong target fails the HID error test; treating only silence as wrong target fails the receiver test. Checked on the XT over wireless: battery, sidetone, sleep timer and lights, each read back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHCVMmAAeV5WGP7oYZsk3T
# Conflicts: # lib/devices/corsair_virtuoso_xt.hpp
From Copilot's review. setLightColor() switched to software mode and then returned on a failed brightness or frame write without switching back, since the color path deliberately has no guard. A failed color left the headset in software mode with nothing worth keeping on its LEDs. SoftwareModeGuard now has dismiss(): the color path takes a guard like every other write and dismisses it only once the frame is written, so success stays in software mode and failure is handed back. A mock test covers both: a clean write takes six requests and never restores hardware mode, and a failed frame write still closes the handle and then restores it. Without the guard the failure case fails; without dismiss() the success case does. On the XT, --light-color still leaves the headset in software mode. API_VERSION goes to 1.6. Action entries gained "color", and 4.1.0 already shipped 1.5, so this cannot fold into 1.5 the way Sapd#560's sidetone field did before that release. The LIBRARY_USAGE.md example and the serializer fixture follow, as in Sapd#549. Also brings in Sapd#570's fixes for stale replies and probe errors, which the light color path picks up through transact(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHCVMmAAeV5WGP7oYZsk3T
|
Addressing the three findings Copilot suppressed in its summary: API version — fixed, now 1.6 (d79a316). Action entries gained
|
The CLI ordering test passed '#FF8000' in single quotes. _popen goes through cmd.exe, which does not treat single quotes as quoting, so the parser got the quotes too and rejected the value - failing the Windows (MinGW) job while Linux and macOS passed. Double quotes quote on cmd.exe and sh alike, and the # still needs quoting on sh, where an unquoted leading # starts a comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHCVMmAAeV5WGP7oYZsk3T
Implements light color support as agreed in #572.
Changes made
Following your answers in #572, point by point:
CAP_LIGHT_COLOR, CLI--light-color RRGGBBwith a leading#accepted, appended at the end ofCAPABILITIES_XLISTand ofCAPABILITY_DESCRIPTORS.headsetcontrol_c.cppnowstatic_asserts the C enum against the C++ one — I went a little further than the count check you asked for and expandedCAPABILITIES_XLISTto assert every entry by name, plus the count. DeletingHSC_CAP_NOISE_FILTERfrom the header (the Added Rich Color Configuration #515 shape) fails the build withhsc_capability_t out of sync with capabilities: CAP_SIDETONE_STATUS; dropping the new entry fails withcount differs.LightColorSettings { uint8_t r, g, b; }inFeatureParam;HIDDevice::setLightColor(hid_device*, const LightColorSettings&); descriptor min/maxnullopt, value hint<RRGGBB>. C API ishsc_set_light_color(h, r, g, b), C++ isHeadset::setLightColor(). The int-vs-struct rule is written down inADDING_A_CAPABILITY.md.000000is valid (on the Virtuoso it goes dark).LightsResult.modeas is.-lwith--light-color: the color request is last in the request table, so it runs after-land wins. Documented in--help, the README usage section, and at the request table.Output: actions carry
color: "#rrggbb"in JSON, YAML and ENV, andvaluepacks0xRRGGBB. Thevalue > 0guard wasn't only inoutput_data.hpp— YAML and ENV each had their own inline copy, so fixing the one line would still have lost black in two formats. The rule now lives once inActionData::hasValue(). It still omits0for other actions, since the equalizer returnssuccess(0)to mean "no value";-l 0output is unchanged.api_versiongoes to 1.6 for the new field, since 4.1.0 shipped 1.5.Also: test device implements it;
ADDING_A_CAPABILITY.mdsays new capabilities append only and must be mirrored in the C header;LIBRARY_USAGE.md,ADDING_A_DEVICE.mdandAGENTS.mdlist the capability (plus noise filter and sidetone status, which those lists had already missed); README table regenerated with--readme-helper.Corsair Virtuoso XT/SE
-lstays the brightness switch from #570;--light-colorpaints the frame through handle 0 / resource 1. Checked on the XT:0x03, as in ckb-next. The XT does return0x03for an already-open handle; with the lighting handle deliberately left open,--light-colorstill succeeds and leaves it closed. The handle is also closed when the frame write fails.-l 0 --light-color 00ff00ends up green.-l 1the way back to the user's effect.One limitation you should weigh: on this headset the color is temporary. With nothing talking to it, the headset drops back to hardware mode by itself — still in software mode 45 s after
--light-color, back in hardware mode by 90 s — and its own effect returns. OpenRGB's Bragi controller refreshes every 30 s for the same reason, and its hardware-mode write is commented out.I looked for a way to store a hardware-mode color. A read-only sweep of every 16-bit property and resource ID on the XT found 33 properties and 6 resources, all below
0x100. The only plausible stores are resources0x14(6 bytes) and0x2a(5 bytes), and both refuse reads — so their contents can't be backed up, and I didn't write to them. A capture of iCUE setting hardware lighting would be the safe way to find out, if iCUE offers that for this headset. The limitation is stated in the device header and in a README note.If you'd rather not advertise a color that lasts about a minute, dropping the last commit leaves the plumbing standing on the test device alone.
Testing
clang-format18 clean over every changed file.ActionData's value rule; the C and C++ API through the test device; CLI output in every format, including black and-lordering. RevertinghasValue()tovalue > 0fails theActionDatatest, and doing so only in the YAML writer fails the CLI test.tests/test_corsair_virtuoso.cpp): a failed color write restores hardware mode while a successful one stays in software mode; stale replies are discarded; probe errors are propagated.0a62) — green after-l 0in the same call,000000goes dark,-l 1restores the headset's own effect; wireless (0a64) — magenta.Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_01BHCVMmAAeV5WGP7oYZsk3T