Skip to content

Add support for Razer BlackShark V2 Pro for PlayStation (1532:1014) - #620

Merged
Sapd merged 4 commits into
Sapd:masterfrom
TheWeaklessOne:feat/razer-blackshark-v2-pro-ps
Nov 5, 2026
Merged

Sapd merged 4 commits into
Sapd:masterfrom
TheWeaklessOne:feat/razer-blackshark-v2-pro-ps

Conversation

@TheWeaklessOne

@TheWeaklessOne TheWeaklessOne commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Changes made

Battery status for the PlayStation edition of the BlackShark V2 Pro through its 2.4 GHz dongle (1532:1014, "BlackShark V2 Pro PS 2.4"). Razer Synapse does not support this edition, so until now there was no way to see the battery level on a PC.

The protocol follows the HID framing used by Razer's firmware updater for this dongle (Airoha chip):

  • 64-byte reports with ID 0x02 on the vendor collection (usage page 0xFF00, usage 0x0001, interface 3)
  • request: 02 80 <len> <crc16 LE> <payload>; the CRC is CRC16-CCITT (poly 0x1021, init 0) over the whole report with the CRC field zeroed
  • each frame is acknowledged through an input report fetched over the control pipe: 'O' accepted, 'F' rejected
  • replies arrive as input reports and are matched by command type and sequence number; byte 1 gives the valid length, the rest of the report is stale data

The headset only answers while the dongle is in "remote" mode. getBattery() checks that a headset is linked (returns BATTERY_UNAVAILABLE when it is off), switches the dongle to remote mode, reads the battery voltage in mV, and always switches back to local mode, the dongle's normal state, verifying that it took effect. Only read commands and this mode switch are sent.

Tray frontends tend to poll every second, so the voltage is re-read (and the dongle switched) at most once a minute per device handle; a failed read is held for 10 seconds, except a failed switch back to local mode, which is retried on the next call. The cheap link-status query still runs on every call, which means switching the headset off is reported immediately and clears the cache. The cache lives in the process, so a frontend that starts headsetcontrol for every poll still switches the dongle each time.

The headset reports a voltage, but no percentage and no charging state, so the level comes from a generic Li-ion curve and should be read as approximate.

Testing

  • Windows 11, dongle firmware 1.0.8: 40 consecutive -b reads succeeded, about 0.9 s each, 3930-3951 mV; the dongle was back in local mode after every read and stayed the default audio device.
  • Unit tests (tests/test_razer_blackshark_v2_pro_ps.cpp) replay a captured session and check the frames byte for byte, and simulate a switched-off headset, a dongle already in remote mode or in an unknown mode, one that will not go back to local mode, the voltage cache (per handle, and after a failure), missing and implausible battery replies, an unplug mid-read, rejected frames and unrelated replies.
  • Unit tests also pass on Linux (GCC 15 and Clang 21 with ASan/UBSan). The device itself is not tested on Linux or macOS, and only on one unit.

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 (n/a: new device only)

🤖 Generated with Claude Code

TheWeaklessOne and others added 2 commits November 5, 2026 06:06
Battery status for the 2.4 GHz dongle of the PlayStation edition. The
dongle uses an Airoha chip and the HID framing of Razer's firmware
updater: 64-byte reports with ID 0x02 on the vendor collection (usage
page 0xFF00, usage 0x0001, interface 3), a CRC16-CCITT over the whole
report, an 'O'/'F' acknowledgement fetched as an input report, and
replies matched by command type and sequence number.

The headset only answers while the dongle is in remote mode, so
getBattery() checks that a headset is linked, switches the dongle to
remote mode, reads the battery voltage in mV, and always switches back
to local mode, its normal state. Only read commands and this mode
switch are sent. The headset reports a voltage but no percentage and no
charging state; the level comes from a generic Li-ion curve.

Tested on Windows 11 with dongle firmware 1.0.8: 40 consecutive reads
succeeded (about 0.9 s each) and the dongle was back in local mode after
each one. The unit tests replay a captured session and simulate a
switched-off headset, missing replies and rejected frames. Not tested on
Linux or macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the awaiting-maintainer The author replied or pushed - our move label Nov 5, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-05T15:27:48.358839Z 8a2f514 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Tray frontends poll every second, and every voltage read switches the
dongle to remote mode and back. The link status is still queried on
each call, so switching the headset off shows up right away, but the
voltage is cached for 60 s and dropped as soon as the headset is seen
unlinked.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e09355d9cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Sapd

Sapd commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Nice capture work - the mock recomputing the CRC on every write is what makes the byte-for-byte frame assertions worth having.

Codex's finding is real, and it contradicts the PR description ("always switches back to local mode ... verifying that it took effect"). Both halves hold:

for (int attempt = 0; attempt < ATTEMPTS && !isMode(device_handle, MODE_LOCAL); ++attempt) {
    (void)setMode(device_handle, MODE_LOCAL);
}
return voltage;

attempt < ATTEMPTS is evaluated first, so on the fourth iteration the loop exits without calling isMode() - the third setMode() is never verified - and every setMode() result is discarded. I reproduced it in your own mock with a knob that acks the switch but keeps mode at MODE_REMOTE: getBattery() returns success while the dongle is still in remote mode. That outlives the call, and your own testing notes say remote mode is where the dongle stops being the default audio device, so it is worth failing on.

This passes with your tests plus that case:

// Local is the dongle's normal mode; restore it even if the read failed.
// Verify the last attempt too: leaving the dongle in remote mode outlives
// this call, so it is reported even when the voltage read succeeded.
bool local = isMode(device_handle, MODE_LOCAL);
for (int attempt = 0; attempt < ATTEMPTS && !local; ++attempt) {
    (void)setMode(device_handle, MODE_LOCAL);
    local = isMode(device_handle, MODE_LOCAL);
}
if (!local) {
    return DeviceError::protocolError("Could not switch the dongle back to local mode");
}
return voltage;

It reports the restore failure in preference to a failed read, which I think is right - the read failing is over, the dongle being left in remote mode is not. Worth a test for it either way; testBlackSharkRestoresLocalAfterFailure only covers the case where the restore works.

Second thing, from the caching commit: cached_voltage_ and cached_at_ are plain members, and the registry hands out one device instance per VID/PID. Two of these dongles on one machine means both share the cache, so the second one reports the first one's voltage for up to a minute. logitech_astro_a50_gen4.hpp and sony_pulse_3d.hpp both key this kind of state by hid_device* and clear it in onConnectionClosed().

Everything else checks out, including CI on 0824418 - note the red-looking runs on e09355d are just the unapproved first-time-contributor ones, superseded.

@github-actions github-actions Bot added awaiting-author We replied - waiting on the author and removed awaiting-maintainer The author replied or pushed - our move labels Nov 5, 2026
Verify the last attempt to switch the dongle back to local mode, and
fail the call when it did not take, even if the voltage was read: remote
mode outlives the call and takes the dongle off as the default audio
device.

Key the voltage cache by hid_device* and drop it in onConnectionClosed(),
so two dongles no longer share it. A failed read is held for 10 seconds,
so a headset that is linked but does not answer is not switched on every
poll; only a failed restore is retried on the next call.

Also leave the dongle alone when it reports a mode other than local or
remote, and treat a non-zero status on SET_MODE as a failure.

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

Copy link
Copy Markdown
Contributor Author

Thanks for reproducing it. Both points are fixed in 8a2f514.

  • The restore loop is your version: the last attempt is verified, and a dongle left in remote mode fails the call even when the voltage was read. testBlackSharkReportsStuckRemote covers it with an ignore_local knob in the mock, including the case where the read failed too and the restore error wins.
  • The cache is keyed by hid_device* and cleared in onConnectionClosed(), like in the PULSE 3D driver. testBlackSharkCachePerHandle checks that two handles keep separate values and that closing one only drops its own.

While at it I changed a few related things:

  • A failed read is now held for 10 seconds. Before, a headset that is linked but does not answer made every poll of a tray app go through the whole remote/local switch, with up to a few seconds of timeouts each time. A failed restore is still retried on the next call.
  • If the dongle reports a mode other than local or remote, the driver now leaves it alone instead of forcing it to local.
  • A non-zero status in the reply to SET_MODE now counts as a failure.
  • The mock can now inject HID write errors, and there are tests for an unplug in the middle of a read and for the cache expiring at exactly 60 s.

Unit tests pass on Windows (MinGW, GCC 16) and on Linux (GCC 15 and Clang 21 with ASan/UBSan, Ubuntu under WSL, without the dongle). On the hardware, 20 separate -b runs all succeeded with the dongle staying the default playback and recording device, and -f 1 -b for two minutes re-read the voltage once a minute and answered the polls in between from the cache.

@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 Nov 5, 2026
@Sapd
Sapd merged commit 546969a into Sapd:master Nov 5, 2026
11 of 12 checks passed
@github-actions github-actions Bot removed the awaiting-maintainer The author replied or pushed - our move label Nov 5, 2026
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