
Razer BlackShark V2 Pro (2023) battery % on Linux — fully reverse-engineered, working tray widget. The firmware really is "allergic to Linux", and here's exactly why + the fix
TL;DR: The BlackShark V2 Pro 2023 dongle (1532:0555) deliberately disables its vendor/control channel when it detects a Linux host — it fingerprints the OS from the very first USB descriptor read after power-on. A pending kernel patch defeats the fingerprint, but even then the protocol is undocumented AND the dongle silently drops commands unless every one is preceded by an undocumented "relay poke" frame. I have all three pieces solved and a working KDE tray widget showing battery % and charging state. Full protocol + scripts below.
The problem
No battery readout for this headset on Linux. Ever. Not in upower, not in KDE, nothing:
headsetcontrol— supports zero Razer devices- OpenRazer — no support for
1532:0555; the support requests (#2184, #2451) have been open since Dec 2023 - The classic Razer 90-byte feature-report protocol (what OpenRazer speaks) — the device doesn't implement it at all. Its HID report descriptor exposes only 64-byte interrupt IN/OUT reports on report ID
0x02. Every feature-report attempt times out. - Even raw USB control transfers with a proper interface claim — timeouts on every transaction ID, every interface, every recipient type.
The device wasn't ignoring bad commands. It was ignoring everything, while audio worked perfectly.
Discovery part 1: the firmware fingerprints your OS
The breakthrough lead came from this Razer Insider thread where someone spent three weeks on the BlackShark V3 and found identical symptoms: byte-identical commands work on Windows and are ignored on Linux, and even hardware button events are gated.
The mechanism (from a patch later submitted to the linux-usb mailing list): the dongle fingerprints the host based on the first GET_DESCRIPTOR(CONFIGURATION) read after power-on. Windows requests 255 bytes; Linux requests the 9-byte header first. If the dongle sees the 9-byte read, it disables the entire vendor command channel for the rest of the power session. Resets, re-enumerations, and byte-exact replays of Windows traffic don't recover it — only physically removing power (unplugging) does, and then Linux immediately re-poisons it at the next enumeration.
There is a patch in review on linux-usb adding USB_QUIRK_CONFIG_DESC_READ_255 (a quirk that makes the first config read request 255 bytes, exactly like Windows), including a runtime quirk letter so you can apply it to any VID:PID from the kernel command line:
The static entry in the patch covers the V3 Pro (1532:0577). I built a kernel with the patch and applied it to the V2 Pro 2023 at runtime:
usbcore.quirks=1532:0555:r
Verified with usbmon: with the quirk, Linux's first config read requests 255 bytes, and the vendor channel comes alive — the dongle starts emitting event frames it never sent before. I can confirm the V2 Pro 2023 fingerprints identically to the V3 Pro. (If you're reading this after the patch is mainlined, you may only need the cmdline quirk — check whether your kernel has USB_QUIRK_CONFIG_DESC_READ_255.)
Important: after enabling the quirk you must physically unplug and replug the dongle — the poisoned state persists until power is removed.
Discovery part 2: the protocol is not the classic Razer protocol
With the channel armed, the classic 0x07/0x80 battery command still got nothing — this firmware generation speaks a different, unpublished dialect. So: Wireshark + USBPcap on a Windows machine with Synapse installed, dongle plugged in, battery visible on screen, capture everything.
The captures revealed a request/reply protocol inside 64-byte HID interrupt reports:
Host request ("PA" frame), written to the hidraw device:
[0]=0x02 [1]=0x80 [2]=inner_len [3..4]=0x00 [5..]=inner, pad to 64
inner = 50 41 <target> 00 <payload_len> <cmd> <args...>
'P' 'A'
Device reply ("PI" frames), read from the same hidraw device (one 64-byte report can contain multiple subframes; byte[1] of the report = total valid length, the rest of the buffer is stale garbage — ignore it):
subframe = 50 49 <source> <ctr:3> 00 00 <len:2 LE> <payload>
'P' 'I'
Targets: 0x0e = the dongle itself, 0x08 = the headset. Commands (verified):
| cmd | meaning | reply payload |
|---|---|---|
0x02 |
hello / open | capability blob |
0x21 |
battery | 21 <status> 01 <percent> — direct 0–100 |
0x2a |
charging | 2a <status> 01 <flag> — 0 = not charging |
0x20 |
device info | (serial etc. seen in event stream) |
Status byte in replies is 0x01 or 0x02 — accept both. Sources 0x01/0x11 are monitor/event channels (heartbeats ~1–5 s while the RF link is up; useful as a "headset is on" presence signal).
The headset has (at least) two target-address states. During the pre-poke investigation, there was a period where the headset ignored 0x08 entirely but answered the same commands at target 0x1c — observed while it was charging — with replies still sourced as 0x08. The heartbeat frames also carry a byte that read 0x1c during that period, which looks like the device announcing its current/alternate address on the event stream. After discovering the relay poke, 0x08 has worked in every state I've tested (charging and not), so the poke may make the distinction moot — but the dual-address behaviour is real, observed, and unexplained. If anyone digs further: the address byte in heartbeat payloads (11 <counter> <addr> on source 0x01) is where to look.
The related blackshark-linux project (BlackShark V3 Pro) documents a sibling 64-byte protocol with the same command IDs (battery 0x21 etc.) but different framing — helpful confirmation, not directly usable on the V2 Pro 2023.
Discovery part 3: the trap that cost a whole day — the relay poke
Here's the evil part. With the quirk active and the correct frames, queries sometimes worked and mostly didn't — the dongle would accept the USB write (clean completion, verified in usbmon) and then silently drop headset-addressed frames. No error, no NAK, no echo. Meanwhile heartbeats kept flowing and the dongle answered queries addressed to itself. We chased phantom theories for hours: settle time, session slots, charger state, Bluetooth interference, hidden nonces (checked — Synapse's frames are byte-identical every time, no rolling keys).
The answer was in a second capture — this time of Synapse's first contact (capture running before the dongle was plugged in). Synapse escorts every headset-addressed command with a poke to the dongle first:
→ 50 41 0e 00 02 e1 01 ; poke the dongle ("forward the next one")
→ 50 41 08 00 03 21 00 00 ; battery query to the headset
e1 01, target 0x0e, before every single command, ~50–80 ms apart. In the steady-state capture these frames were easy to misread as EQ reads (on the V3, 0xe1 is EQ-related). Sent to the dongle, it's a relay-forward poke — and without it the dongle drops your command on the floor.
With the poke: instant replies, every time, no power cycles, no settle time, fully repeatable. That one 13-byte frame was the entire "mystery".
The working solution
Prerequisites: kernel with USB_QUIRK_CONFIG_DESC_READ_255 (patch above; on Arch-likes, rebuild your kernel package with the patch — it's ~20 lines across 3 files and applies cleanly to 6.x/7.x-era trees), plus:
# kernel cmdline
usbcore.quirks=1532:0555:r
Reboot, then physically replug the dongle.
udev rule (/etc/udev/rules.d/70-blackshark.rules — must sort before 73-*-uaccess):
KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="0555", TAG+="uaccess"
The query script (stdlib-only Python, no dependencies, ~1 s per read, prints e.g. 🎧 61% / 🎧⚡ 61%, empty when the headset is off):
#!/usr/bin/env python3
# Razer BlackShark V2 Pro 2023 battery for Linux
import glob, os, select, time
def find_node():
for ue in glob.glob('/sys/class/hidraw/hidraw*/device/uevent'):
try:
if '00001532:00000555' in open(ue).read():
return '/dev/' + ue.split('/')[4]
except OSError:
pass
return None
def build(tgt, cmd, args=b'\x00\x00'):
payload = bytes([cmd]) + args
inner = bytes([0x50, 0x41, tgt, 0x00, len(payload)]) + payload
b = bytearray(64)
b[0] = 0x02; b[1] = 0x80; b[2] = len(inner)
b[5:5+len(inner)] = inner
return bytes(b)
def subframes(d):
n = d[1]; i = 2
while i + 10 <= 2 + n:
if d[i] == 0x50 and d[i+1] == 0x49:
plen = d[i+8] | (d[i+9] << 8)
yield d[i+2], bytes(d[i+10:i+10+plen])
i += 10 + plen
else:
i += 1
def main():
dev = find_node()
if not dev:
return ''
try:
fd = os.open(dev, os.O_RDWR | os.O_NONBLOCK)
except OSError:
return ''
batt = chg = None
POKE = build(0x0e, 0xe1, b'\x01') # the magic relay poke
try:
for cmd in (0x02, 0x2a, 0x21): # hello, charging, battery
os.write(fd, POKE); time.sleep(0.06)
os.write(fd, build(0x08, cmd)); time.sleep(0.06)
end = time.time() + 2.0
while time.time() < end and (batt is None or chg is None):
r, _, _ = select.select([fd], [], [], 0.15)
if r:
d = os.read(fd, 64)
for _t, pl in subframes(d):
if len(pl) >= 4 and pl[1] in (0x01, 0x02):
if pl[0] == 0x21: batt = pl[3]
elif pl[0] == 0x2a: chg = pl[3]
finally:
os.close(fd)
if batt is None:
return ''
return f'🎧{"⚡" if chg else ""} {batt}%'
if __name__ == '__main__':
print(main())
Tray display: KDE users — Command Output plasmoid pointed at the script, 60 s interval. Waybar/Polybar users — it's just a script that prints a string; drop it in a custom module.
Caveats & notes
- Verified on the 2023 model (RZ04-0453, dongle
1532:0555) only. The V3 family fingerprints the same way per the patch; the protocol framing likely differs per generation. - The
e1 01poke semantics are empirical — it works 100% of the time here, but nobody outside Razer knows its actual name. - If queries ever go dead: replug the dongle (any pre-quirk enumeration or power glitch re-poisons it until power is removed).
- If
0x08gets no reply even with the poke: try target0x1c(the observed alternate address — seen while charging, pre-poke), or read the current address from the heartbeat stream. Easy to add as a fallback loop in the script. - Everything here was derived from my own hardware via standard interoperability reverse-engineering — packet captures of my own devices, public kernel patches, and open-source references (RazerBatteryTaskbar, blackshark-linux, OpenRazer source).
Credits
- Charles D. Aoust — the config-descriptor fingerprint discovery and the kernel quirk patch on linux-usb
- The anonymous BlackShark V3 owner on Razer Insider whose three weeks of pain pointed everyone at the firmware
- The OpenRazer project — protocol reference for the classic command set
- RiskRunner0's blackshark-linux — the V3 Pro protocol documentation that confirmed the command IDs
If you have this headset and try it, report back — and if you're on the V3/V3 Pro, the blackshark-linux project above is your starting point. I'll be sending a Tested-by for 1532:0555 to the lore thread so the quirk hopefully ships in mainline with both devices covered.