Skip to content

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

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

gdevenyi wants to merge 6 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.

gdevenyi added a commit to gdevenyi/HeadsetControl that referenced this pull request Sep 14, 2026
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
@gdevenyi gdevenyi mentioned this pull request Sep 14, 2026
2 tasks done
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
gdevenyi added a commit to gdevenyi/HeadsetControl that referenced this pull request Sep 14, 2026
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

One more commit here, 8feff12, from Copilot's review of the stacked #580. Both findings were in code this PR introduces:

  • Late replies are now discarded instead of documented. Replies carry no property ID, so a reply that outlived its request's timeout could be taken by the next request of the same kind. Every request now goes through transact(), which throws away anything already queued before writing. This replaces the comment added for your 300 ms probe point.
  • resolveTarget() no longer turns every probe failure into "offline". Silence and "no such property" (a receiver answering for itself with no headset paired) still mean wrong target; a HID write failure or an unexpected status is returned.

Both come with mock tests in a new tests/test_corsair_virtuoso.cpp, whose mock makes each report readable only after a given number of writes so a stale reply can be modelled. Each test fails with its fix reverted. Checked on the XT over wireless: battery, sidetone, sleep timer and lights all read back.

@Sapd

Sapd commented Sep 22, 2026

Copy link
Copy Markdown
Owner

SE results came in on #568 from @zoeyronain, who has the hardware - worth reading, you weren't tagged.

Good: battery reads 96% wired and through the receiver, 97% charging, and they confirmed it dropping over a few hours. So the Bragi battery fix is verified on both SE PIDs.

Not good: -s does nothing on the SE. alsamixer shows the sidetone control pinned at 10 whether they set 64 or 0. This PR advertises CAP_SIDETONE for 0a3e/0a3d, and it also rewrites the README note to say the XT/SE are the Virtuosos that don't use the ALSA mixer for sidetone - their screenshot suggests the SE does.

Two asks: gate CAP_SIDETONE on the XT PIDs unless you can explain the SE result, and recheck that README note.

Also their lights test was 2026-09-11, so it was the old painted-frame code. Brightness-as-switch is still unverified on SE.

@zoeyronain

Copy link
Copy Markdown
Contributor

@Sapd do you want me to test the latest code for the lights?

@zoeyronain

zoeyronain commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

wireless:
image

wired

image image

@Sapd

Sapd commented Sep 27, 2026

Copy link
Copy Markdown
Owner

@zoeyronain thanks for testing.

Wireless alone looks right: battery 90% with BATTERY_CHARGING detected, -l 1 and -l 0 both succeed.

With the cable and the receiver both plugged in, everything fails:

0x0a3d (wired SE)  Error: [battery] Protocol error
0x0a3e (receiver)  Error: [battery] Device is offline or not responding

Same for -l 1.

@gdevenyi the receiver reporting offline is presumably right - you noted the headset re-enumerates as the wired PID when the cable goes in, so nothing is paired to it. The wired SE returning a protocol error is the problem, and that's the case resolveTarget() exists for. Note 8feff12 is what surfaced it - before that it would have come back as "offline" and looked like ordinary absence.

Still open from my last comment: -s does nothing on the SE (alsamixer pinned at 10), while this PR advertises CAP_SIDETONE for 0a3e/0a3d and rewrites the README note to say the XT/SE are the Virtuosos that don't use the ALSA mixer.

@Sapd

Sapd commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Correcting myself: the wired SE is a regression from 8feff12 / 9f54a74, not something they surfaced.

@zoeyronain tested the same branch on 2026-09-11 (screenshots in #568, now closed). Both devices plugged in, before those two commits:

0x0a3d (wired SE)  BATTERY_CHARGING, 96%
0x0a3e (receiver)  offline

and -l 1 / -l 0 both reported success on 0x0a3d. On 2026-09-22, same setup, 0x0a3d returns a protocol error. So it worked - battery and lights - and broke between the two.

My guess is resolveTarget(): it now returns any unexpected status as a hard error instead of falling through to the other target, so a reply that used to mean "wrong target, try 0x09" aborts the call.

The receiver reporting offline is the same in both runs and is correct, nothing is paired to it once the headset re-enumerates on the cable.

Also from #568, J-eremy on 2026-09-03: "the headset wired uses a completely different protocol". That sits oddly with his 2026-08-28 note that the wired SE does speak V2W on endpoint 0x08. Both are in that thread.

Still open: -s does nothing on the SE while this advertises CAP_SIDETONE for 0a3e/0a3d.

Further SE testing should go here, #568 is closed.

@github-actions github-actions Bot added awaiting-author We replied - waiting on the author needs-rebase Branch conflicts with master labels Oct 20, 2026
gdevenyi and others added 2 commits October 23, 2026 19:24
…on XT only

resolveTarget() returned any reply that was not silence or "no such
property" as a hard error, which broke the wired SE with its receiver
also plugged in: its answer on the target it does not use is a protocol
error, so the call stopped there instead of trying the other target.
A protocol error now falls through to the other target and is only
reported if neither target answers. HID errors still fail immediately.

Sidetone is advertised on the XT only. The SE also has an ALSA sidetone
control, which the XT lacks, and on an SE that control did not move
when the Bragi properties were written. The README marks sidetone as
variant-specific and puts the SE back in the ALSA-sidetone note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added awaiting-maintainer The author replied or pushed - our move and removed awaiting-author We replied - waiting on the author labels Oct 23, 2026
@github-actions github-actions Bot removed the needs-rebase Branch conflicts with master label Oct 23, 2026
@gdevenyi

Copy link
Copy Markdown
Contributor Author

Both open points are addressed in 125fb7c. I also merged master in (d9d4832); the only conflict was the README table.

Wired SE regression: you're right that it came from 8feff12, and my claim there that "only two probe failures mean wrong target" was wrong. On the target it doesn't use, a wired SE answers in a way that comes out as a protocol error instead of staying silent. An XT stays silent, which is why I never saw this. With both the cable and the receiver plugged in, my XT returns charging on 0a62 and offline on 0a64, before and after this change. So the SE behaves differently, and I can't reproduce it here.

resolveTarget() now tries the other target after a protocol error too, and keeps the error. If neither target answers, the protocol error is what gets returned rather than "offline", so a real fault still isn't hidden. HID errors still fail at once without trying the other target. Two new mock cases:

  • wired SE PID, 0x08 answers with status 0x09 and 0x09 answers OK → battery succeeds, and the follow-up request goes to 0x09. This fails with the fix reverted.
  • both targets give a protocol error → ProtocolError, not DeviceOffline.

Sidetone: CAP_SIDETONE is now advertised for 0a64/0a62 only, following the Nova 7 pattern. I think the explanation is this: the XT has no ALSA sidetone control at all. amixer contents on both of my XT cards shows only PCM playback and headset capture. The SE's alsamixer screenshot shows one, so the SE must handle sidetone differently, and I have no evidence the Bragi properties do anything there. The README shows sidetone as x* with a footnote, and the ALSA note now reads "Virtuoso other than the XT", so the SE is back in it.

@zoeyronain, could you run this branch again with the cable and the receiver both plugged in? Please use -o json so the error message is visible (the plain output only says "Protocol error"):

headsetcontrol -b -o json
headsetcontrol -l 0
headsetcontrol -l 1

With the fix, the wired SE should report battery again. For the lights, please watch the headset: -l 0 should turn the LEDs off, and -l 1 should bring back the headset's own effect, not white. Your earlier lights test was the old painted-frame code, so brightness as the on/off switch is still untested on an SE.

@Sapd

Sapd commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Both fixes check out here.

  • Deleting the ProtocolError branch fails "a protocol error on one target should fall through to the other"; dropping the sidetone gate fails "the SE should not advertise sidetone". The gate matches the Nova 7 pattern and --readme-helper walks the product IDs, so x* is generated rather than written by hand.
  • The whole README table matches the generator, not just your row, so the conflict resolution in d9d4832 is clean.
  • The extra probe can't pick up the first target's late reply: transact() runs discardStaleReports() before every request. That was my worry with the fallthrough and 8feff12 already covers it.
  • Sidetone explanation is plausible and the capability is off for the SE either way, so it costs nothing if it's wrong.

One thing your own mock says that your comment doesn't: the wired SE answers on 0x09 and refuses on 0x08, so for 0a3d the wired -> TARGET_SELF hint is backwards and the probe always pays for it. If zoeyronain confirms, hint 0x09 for the wired SE. Not blocking. It also clears up J-eremy's "completely different protocol" - a V2W endpoint and a Bragi target aren't the same thing, so his two notes never conflicted.

What's left is the SE, and only the lights. Brightness-as-the-switch has never run on one - zoeyronain's lights test was the painted-frame code - and brightness 0 persists, so an SE where -l 1 doesn't take leaves someone with dark LEDs and nothing in HeadsetControl to undo it. That's the one case I don't want to ship untested.

If zoeyronain doesn't get back to it I'll take this on the XT evidence: battery on the SE worked before the regression and 125fb7c puts it back, so lights would be the only thing going in unverified. Say so and I'll merge.

@github-actions github-actions Bot added awaiting-author We replied - waiting on the author needs-rebase Branch conflicts with master and removed awaiting-maintainer The author replied or pushed - our move labels Oct 24, 2026
@zoeyronain

zoeyronain commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

headsetcontrol -b -o json:
image

Works

Lights wireless then wired

image

Works

Battery levels wired and wireless do change:

image

and if I try to adjust sidetone:

image

"What's left is the SE, and only the lights. Brightness-as-the-switch has never run on one - zoeyronain's lights test was the painted-frame code - and brightness 0 persists, so an SE where -l 1 doesn't take leaves someone with dark LEDs and nothing in HeadsetControl to undo it. That's the one case I don't want to ship untested."

Let me know how to check that, provided I can set it in OpenLinkHub and have it work in headset control still. just set the color on OLH, then turned it off and on in headset control and it's back to the default color.

The wired SE refuses 0x08 and answers on 0x09, so hinting 0x08 for it
cost a failed probe on every call. Only the wired XT answers for itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gdevenyi

Copy link
Copy Markdown
Contributor Author

@zoeyronain thanks, that covers everything that was left.

  • Battery: the wired SE reports BATTERY_CHARGING 99% again with the receiver plugged in too, and the receiver reports offline, which is correct. Wireless alone reads 98%. So 125fb7c fixes the regression on real hardware.
  • Lights: -l 0 then -l 1 on both the receiver and the wired SE, and the LEDs came back. That is the brightness-0-and-back case Sapd didn't want to ship untested.
  • Sidetone: no longer advertised on the SE, as intended.

About OpenLinkHub: getting the default color back is expected. As far as I can tell OpenLinkHub drives the LEDs in software mode, and HeadsetControl puts the headset back in hardware mode afterwards, so you see the effect stored on the headset rather than OpenLinkHub's color. Lights off and back on is all this PR does, so nothing more is needed for it.

@Sapd your hint point is in 99b8d48. The working battery read settles it: under 8feff12 the protocol error came from the first target tried, which was 0x08, and now that the code moves on to 0x09 the read succeeds. So the wired SE is addressed on 0x09 first, and only the wired XT uses 0x08. The fallthrough tests now use a stale wired-XT hint to get the refusal on 0x08, and there's a new case checking that the wired SE is probed on 0x09 first, with a single probe. That case fails with the old hint. Suite green, clang-format 18 clean.

From my side this is ready.

@github-actions github-actions Bot added awaiting-maintainer The author replied or pushed - our move and removed awaiting-author We replied - waiting on the author labels Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-maintainer The author replied or pushed - our move needs-rebase Branch conflicts with master

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants