Skip to content

Add support for Sony PULSE 3D (CFI-ZWH1) - #610

Open
Ashraf-Obay wants to merge 2 commits into
Sapd:masterfrom
Ashraf-Obay:add-sony-pulse-3d
Open

Ashraf-Obay wants to merge 2 commits into
Sapd:masterfrom
Ashraf-Obay:add-sony-pulse-3d

Conversation

@Ashraf-Obay

Copy link
Copy Markdown

Changes made

Adds battery status for the Sony PULSE 3D wireless headset (CFI-ZWH1, the PS5 headset) through its USB dongle 054c:0d5e.

Protocol (found by passive listening; nothing is written to the device)

HID is on interface 3, which has four top-level collections. The headset status is on the vendor collection (usage page 0xFF01, usage 0x0020) as an 8-byte input report:

Byte Meaning
0 report ID 0xB0
3 battery 0–100 in steps of 10, or 0x80 while charging
4 bit 0x08 set while the headset is connected, cleared on power-off

The other bytes (a 0–100 level and some button flags) aren't decoded.

Evidence: a 6-hour log on real hardware showed byte 3 stepping 80 → 70 → 60 → 50 → 40 → 30 about once an hour. Power-off/on, plugging the charge cable in and out, and button presses were each checked separately.

The catch: the dongle never answers queries

It only sends the status report when something changes (power on/off, a button press, the charge cable, or a battery step, about hourly). Feature reports on the vendor collections read back as zeros. The collection also declares a 1-bit output report 0xB1, but the dongle rejects writes to it (ERROR_GEN_FAILURE on both interrupt OUT and SET_REPORT), so I couldn't find a way to request a report.

So getBattery():

  1. drains the reports already queued on the (cached) connection and keeps the newest status per handle, so long-running callers like GUIs/tray apps see every change;
  2. if this connection hasn't seen a status yet, waits up to hsc_device_timeout for the next one, and otherwise returns a timeout telling the user to press a headset button;
  3. clears the per-handle state in onConnectionClosed(), as logitech_astro_a50_gen4.hpp does.

Mapping: powered off → BATTERY_UNAVAILABLE (-1). Charging → BATTERY_CHARGING with the last level seen before plugging in, or -1 if none was seen.

Tests: tests/test_sony_pulse_3d.cpp covers newest-report-wins with other collections' reports in between, state kept across calls and per handle, charging, power-off, and error propagation, using the real captured reports. It also asserts that nothing is written to the device.

Tested on hardware: Windows 11 with the CI-built headsetcontrol.exe from this branch:

  • the device is detected as Sony PULSE 3D [0x054c:0x0d5e];
  • -b with no headset event times out after the configured --timeout (checked at 2 s, 5 s and 10 s) with the "press a button" error and BATTERY_UNAVAILABLE;
  • -b --timeout 30000 with one volume press during the wait → BATTERY_AVAILABLE, level 20.

I verified the charging and power-off flags on the hardware with a standalone listener; in this branch they're covered by unit tests. Linux and macOS are untested (no hardware/OS access). The device only uses the usage-page lookup with an interface-3 fallback and filters by report ID, so reports from the other collections on that interface are ignored.

The one-shot CLI is the worst case for this device (a press is needed during the wait). Long-running library users that keep the Headset alive get the queued reports and stay current.

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

(Not a new feature, just a new device using the existing battery capability.)


🤖 Generated with Claude Code

Ashraf-Obay and others added 2 commits October 25, 2026 00:26
Battery status for the PULSE 3D wireless headset via its USB dongle
(054c:0d5e). The dongle never answers queries; it sends an 8-byte status
report (ID 0xB0) on the vendor collection (usage page 0xFF01, usage 0x20)
only when something changes:

  byte[3]  battery 0-100 in steps of 10, or 0x80 while charging
  byte[4]  bit 0x08 set while the headset is connected

getBattery() drains the reports queued on the cached connection, keeps
the newest status per handle, and otherwise waits up to the device
timeout for the next report. Nothing is written to the device.

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 Oct 24, 2026
@Sapd

Sapd commented Oct 26, 2026

Copy link
Copy Markdown
Owner

Good protocol write-up, and the passive approach is the right call - the mock asserting writes == 0 is what makes that claim checkable rather than a promise in prose. Built it here: clean, no warnings, suite green, clang-format 18 clean, README matches --readme-helper. The per-handle state plus onConnectionClosed() follows the ASTRO A50 Gen 4 precedent, including the mutex.

I broke each behaviour to see what the tests hold. Three of four fail as they should: clearing state on close, the 0x80 charging marker, and the connected flag. The fourth doesn't.

The report-ID filter isn't covered. Drop report[0] != STATUS_REPORT_ID from remember() and the suite still passes, because MEDIA_KEY is 5 bytes and report.size() < REPORT_SIZE rejects it first - the ID comparison never runs. That filter is the whole basis for "reports from the other collections on that interface are ignored", so it's worth a report that actually reaches it:

// Same length as a status report but another collection's report ID
const std::vector<uint8_t> OTHER_COLLECTION_8B { 0x01, 0x05, 0x00, 0x64, 0xef, 0x15, 0x11, 0x64 };

queued after AT_30 in testPulse3DLatestQueuedReportWins. Checked both ways here: passes with the filter, fails with "newest report should win" without it.

The collection comment is out of date. Lines 70-71 and the PR body say the usage lookup is Windows-only with an interface-3 fallback elsewhere. #596 is in your history and it made the usage_page/usage match run on all platforms, so Linux and macOS also select the right collection whenever hidapi fills those fields; the interface fallback only bites on older hidapi.

Keep PLATFORM_ALL. Untested isn't a reason to narrow - we narrow where there's a mechanism for breakage, and there isn't one here: nothing is written, and a wrong collection just yields no 0xb0 reports, so the worst case is your timeout message rather than a wrong setting.

Not yours to fix: -b while charging with no earlier level prints Level: -1%. hyperx_cloud_alpha_wireless.hpp:95 and hyperx_cloud_flight.hpp:106 already ship that same -1 with BATTERY_CHARGING, and the standard output prints the level for anything that isn't BATTERY_UNAVAILABLE. Your device makes it the normal case rather than a corner, since byte 3 is 0x80 for the whole charging session and a one-shot run starts with no remembered level. I'll fix the output layer separately.

Those two 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 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-author We replied - waiting on the author needs-rebase Branch conflicts with master

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants