Skip to content

Add CAP_LIGHT_COLOR (--light-color RRGGBB), with Corsair Virtuoso XT/SE support - #580

Open
gdevenyi wants to merge 9 commits into
Sapd:masterfrom
gdevenyi:light-color
Open

gdevenyi wants to merge 9 commits into
Sapd:masterfrom
gdevenyi:light-color

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Implements light color support as agreed in #572.

Stacked on #570. The Virtuoso commit builds on it, so #570's commits (and merges of master and #570) show up in this diff until #570 lands; I'll rebase onto master then. The new work is the last two commits: Add CAP_LIGHT_COLOR (plumbing, reviewable on its own) and corsair_virtuoso_xt: implement light color.

Changes made

Following your answers in #572, point by point:

  1. Separate capability. CAP_LIGHT_COLOR, CLI --light-color RRGGBB with a leading # accepted, appended at the end of CAPABILITIES_XLIST and of CAPABILITY_DESCRIPTORS. headsetcontrol_c.cpp now static_asserts the C enum against the C++ one — I went a little further than the count check you asked for and expanded CAPABILITIES_XLIST to assert every entry by name, plus the count. Deleting HSC_CAP_NOISE_FILTER from the header (the Added Rich Color Configuration #515 shape) fails the build with hsc_capability_t out of sync with capabilities: CAP_SIDETONE_STATUS; dropping the new entry fails with count differs.
  2. Struct in the variant. LightColorSettings { uint8_t r, g, b; } in FeatureParam; HIDDevice::setLightColor(hid_device*, const LightColorSettings&); descriptor min/max nullopt, value hint <RRGGBB>. C API is hsc_set_light_color(h, r, g, b), C++ is Headset::setLightColor(). The int-vs-struct rule is written down in ADDING_A_CAPABILITY.md.
  3. All zones. Zone count stays private to the device. 000000 is valid (on the Virtuoso it goes dark).
  4. Effects untouched; LightsResult.mode as is.

-l with --light-color: the color request is last in the request table, so it runs after -l and wins. Documented in --help, the README usage section, and at the request table.

Output: actions carry color: "#rrggbb" in JSON, YAML and ENV, and value packs 0xRRGGBB. The value > 0 guard wasn't only in output_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 in ActionData::hasValue(). It still omits 0 for other actions, since the equalizer returns success(0) to mean "no value"; -l 0 output is unchanged. api_version goes to 1.6 for the new field, since 4.1.0 shipped 1.5.

Also: test device implements it; ADDING_A_CAPABILITY.md says new capabilities append only and must be mirrored in the C header; LIBRARY_USAGE.md, ADDING_A_DEVICE.md and AGENTS.md list the capability (plus noise filter and sidetone status, which those lists had already missed); README table regenerated with --readme-helper.

Corsair Virtuoso XT/SE

-l stays the brightness switch from #570; --light-color paints the frame through handle 0 / resource 1. Checked on the XT:

  • Close and reopen on open status 0x03, as in ckb-next. The XT does return 0x03 for an already-open handle; with the lighting handle deliberately left open, --light-color still succeeds and leaves it closed. The handle is also closed when the frame write fails.
  • Brightness gates frames too — a green frame stays dark at brightness 0 and shows at 1000. So a color raises brightness ("color implies on"), and -l 0 --light-color 00ff00 ends up green.
  • Restoring hardware mode replaces the frame with the headset's own effect immediately, so a color write stays in software mode. That makes -l 1 the 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 resources 0x14 (6 bytes) and 0x2a (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

  • Unit suite green; clang-format 18 clean over every changed file.
  • New tests: the hex parser; ActionData's value rule; the C and C++ API through the test device; 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.
  • Virtuoso mock tests (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.
  • Hardware, through the CLI and confirmed visually: wired (0a62) — green after -l 0 in the same call, 000000 goes dark, -l 1 restores the headset's own effect; wireless (0a64) — magenta.

Checklist

🤖 Generated with Claude Code

https://claude.ai/code/session_01BHCVMmAAeV5WGP7oYZsk3T

gdevenyi and others added 5 commits September 5, 2026 10:47
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
Copilot AI lite review requested due to automatic review settings September 14, 2026 12:12

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

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.color changes the structured output schema, but API_VERSION remains 1.5 (line 29). docs/LIBRARY_USAGE.md:97-104 defines 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 --connected implementation in cli/main.cpp:1209-1210 only treats BATTERY_AVAILABLE as connected. Consequently headsetcontrol --connected reports false for 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

  • resolveTarget treats every failed readProperty as 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.

Comment on lines +277 to +279
if (auto result = writeProperty(device_handle, target, PROP_BRIGHTNESS, BRIGHTNESS_MAX);
!result) {
return result.error();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +618 to +621
if (result.error().code == DeviceError::Code::Timeout) {
return DeviceError::deviceOffline("Headset not connected or powered off");
}
return result.error();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

gdevenyi and others added 3 commits September 14, 2026 18:14
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
@gdevenyi

Copy link
Copy Markdown
Contributor Author

Addressing the three findings Copilot suppressed in its summary:

API version — fixed, now 1.6 (d79a316). Action entries gained color, and 4.1.0 already shipped API 1.5, so this can't fold into 1.5 the way #560's sidetone field did before that release. Following #549: the constant gets its note, and the LIBRARY_USAGE.md example and serializer fixture follow.

resolveTarget() swallowing errors — fixed, in #570 (8feff12) and merged here. Only two probe failures now mean "wrong target": silence, and "no such property", which is a receiver answering for itself with no headset behind it. Anything else — a failed HID write, an unexpected status — is returned as is instead of becoming "offline". The mock tests check all three cases, including that treating only silence as "wrong target" would regress a receiver with its headset off from "offline" to "not supported".

--connected reporting a charging headset as disconnected — real, but not changed here. It's not specific to this PR: checkDeviceConnected() has treated only BATTERY_AVAILABLE as connected since the C version (info.status != BATTERY_AVAILABLEfalse), and that applies to all 22 device files that report BATTERY_CHARGING. The Virtuoso only meets it now because #570 made it report charging at all. Counting charging as connected is a one-line change, but it alters the meaning of --connected for every device, so I'd rather not slip it into a light-color PR. @Sapd — happy to send it separately if you agree charging should count as connected.

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
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