Skip to content

Audeze Maxwell 2 (3329:4b29): device returns zero-length responses to all status queries; battery and chatmix unavailable #573

Open

Description

@johnsideserf

Summary

On my Maxwell 2 PS/PC dongle (3329:4b29), every info capability reports as
unavailable: battery is BATTERY_UNAVAILABLE / -1, chatmix is always 0.
Setters work — sidetone changes are plainly audible — so the device is
reachable and the write path is fine.

Probing the HID interface directly shows the device replies to every status
request with a zero-length payload. It is not that the response is
misparsed; there is no data in it to parse.

This looks like the same underlying problem as #460 (Maxwell 1), and the two
drivers send byte-identical packets, so a fix likely covers both.

Environment

Device Audeze Maxwell 2, PS/PC dongle 3329:4b29
HeadsetControl 4.0.0 (Arch headsetcontrol 4.0.0-1)
hidapi 0.15.0
OS / kernel Arch Linux, 7.1.9-arch1-2
Firmware updated at some point via Audeze HQ on Windows (exact version unknown)

What headsetcontrol reports

$ headsetcontrol -b -m -o json
      "device": "Audeze Maxwell 2",
      "id_vendor": "0x3329",
      "id_product": "0x4b29",
      "battery": { "status": "BATTERY_UNAVAILABLE", "level": -1 },
      "chatmix": 0

What the device actually returns

I replayed the driver's exact sequence — all 15 UNIQUE_REQUESTS then all 6
STATUS_REQUESTS, 62-byte writes, 60ms delays, HIDIOCGINPUT with
buf[0] = 0x07 — straight against /dev/hidraw4. Every single response, for
all 21 packets:

07 00 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ...

All payload bytes zero. No D6 0C 00 00 battery marker anywhere in any
response, so getDeviceStatus() takes the BATTERY_UNAVAILABLE path
correctly — there is genuinely nothing there.

Two details that suggest the device understands the framing and is declining
the command rather than failing to parse it:

  1. Byte 1 looks like a length field, and the device answers with zero.
    Requests carry 0x08 / 0x07 / 0x09 there; every reply carries 0x00.
  2. Byte 2 mirrors the request. All requests with byte 2 = 0x80 get
    07 00 80 ... back. UNIQUE_REQUESTS[6], the only one with byte 2 = 0x00,
    gets 07 00 00 ... back. So the dongle is echoing header state, not
    returning payload.

Transports tried

Transport Result
hid_write + hid_get_input_report (what the driver does) 07 00 80 00..., zero payload
Interrupt IN endpoint (plain read(), 1s window, before and after a query) nothing at all, 0 reports
Feature reports (HIDIOCSFEATURE / HIDIOCGFEATURE) zeros
HIDIOCGINPUT on every declared report ID (3, 4, 5, 6, 7, 10, 12) only ID 7 returns non-zero, and only the 07 00 80 header

Ruled out

  • Permissions — the shipped rule matches (3329:4b29, TAG+="uaccess"),
    the ACL grants my user rw, and the node opens O_RDWR from an unprivileged
    process.
  • Wrong interface — interface 5 is the only HID interface on the device,
    matching getCapabilityDetail's interface_id = 5.
  • Wrong usage page — the report descriptor declares 0xff13, as expected.
  • Wrong message size — the descriptor gives report 6 output = 61 payload
    bytes and report 7 input = 61, i.e. 62 with the report ID. MSG_SIZE = 62
    is correct.
  • Headset asleep or unlinked — audio streams through the dongle at
    48kHz/24-bit throughout, and the same zero responses come back while it does.
  • Version — the 4.0.0→master diff for audeze_maxwell2.hpp is only the
    status-reuse caching from perf(audeze): answer every info capability from one status read #550; packet sequences and parsing are identical, so
    4.1.0 behaves the same.

Also tried, also no response

Following the RACE protocol notes in
kats1123/maxwell-firmware-research
(which documents this exact framing: 06 [LEN] 80 05 5A [PLEN] 00 [CMD...],
polled GetInputReport on report ID 0x07, and a battery query of
06 07 80 05 5A 03 00 D6 0C — byte-for-byte what the driver sends):

  • Delay between write and read. That write-up notes a read issued
    immediately after a write returns stale data, and sendGetInputReport()
    sleeps 60ms before the write then reads immediately after it. I tried
    delays of 0, 5, 10, 20, 40, 60, 100, 200 and 400ms between the write and the
    read, and polling the input report continuously for 1s. Always
    07 00 80 00 00....
  • Routing byte. Byte 2 appears to select dongle-local (0x00) versus relay
    to headset (0x80). I swept all 256 values for the battery query; none
    produced a non-zero-length reply.

A documented response looks like 05 5B 06 00 01 09 2F 00 00 <state> — RACE
response magic 0x5B and a real length. Nothing I send produces a frame
containing 0x5B at all.

Likely a firmware revision difference

audeze_maxwell.hpp and audeze_maxwell2.hpp contain identical
UNIQUE_REQUESTS and STATUS_REQUESTS arrays. Given #460 reports the same
BATTERY_UNAVAILABLE on Maxwell 1, I suspect a firmware generation that
changed or gated these opcodes, affecting both models rather than one.

If a maintainer can say which firmware version the original implementation was
developed against, I'm happy to compare against mine and help narrow it.

Secondary: setters report success unconditionally

sendGetInputReport() only checks that the read returned MSG_SIZE bytes:

auto read_result = getInputReport(device_handle, tempBuff);
if (!read_result || *read_result != MSG_SIZE) {
    return DeviceError::hidError("Failed to get input report");
}

62 zero bytes satisfy that, so on this hardware every setter returns
success regardless of what the device did:

$ headsetcontrol -s 128 -o json
  "actions": [ { "capability": "CAP_SIDETONE", "status": "success", "value": 128 } ]

Here the write genuinely did apply, so that particular line is honest. But the
check cannot distinguish an applied write from an ignored one, which made this
considerably harder to diagnose — I only established that writes work by
setting sidetone to maximum and listening for it. Treating a zero-length reply
as a failure, or validating the echoed header, would surface real write errors.

Reproduction script

The probe used above is a single standalone Python file with no dependencies
(opens /dev/hidraw*, replays the two sequences, dumps every response and
flags the D6 0C 00 00 marker). Happy to attach it or open a PR adding it
under a debug/ directory if that would be useful for triaging Audeze reports.

Activity

  1. johnsideserf commented on Sep 7, 2026

    @johnsideserf
    Author

    Follow-up with a usbmon capture, which rules out hidapi as a factor and adds
    three findings.

    1. The empty responses are real at the USB level, not a hidapi artifact.

    Captured bus traffic while running headsetcontrol -b three times. All 126
    GET_REPORT transfers (a1 01 0107 0005 003e — input report 0x07,
    interface 5, 62 bytes) completed with status 0, and returned exactly two
    distinct payloads:

    120 x  07 00 80 00 00 00 00 00 ... (all zero)
      6 x  07 00 00 00 00 00 00 00 ... (all zero)
    

    The 6 are the responses to UNIQUE_REQUESTS[6], the only request with byte 2 =
    0x00 — 2 per invocation across 3 invocations. So byte 2 of the reply tracks
    byte 2 of the request, and the frame is otherwise empty. The writes go out
    correctly as 21 09 0206 0005 003e (output report 0x06, 62 bytes).

    2. The interrupt endpoint is functional — it just never carries RACE replies.

    I previously reported seeing nothing on interrupt IN; that was an incomplete
    test. In the same capture the endpoint delivered 68 input reports from button
    presses (report ID 0x0c, Consumer page — volume and mute). So the endpoint
    works, and the absence of RACE responses there is the device's behaviour rather
    than a reading error on my part.

    3. Not a console-mode issue. The Maxwell 2 dongle has a PC/PS/Xbox switch,
    and Audeze note that PC mode gives full functionality. Mine is set to PC, so
    that does not explain it.

    Unrelated, but visible in the capture: on 4.0.0 each headsetcontrol -b
    invocation sends the 21-packet sequence twice (126 transfers over 3 runs),
    once for battery and once for chatmix. That is what makes a single call cost
    ~2.6s here rather than ~1.3s. The status reuse added in #550 should collapse
    that to one sequence in 4.1.0 — noting it only to confirm the caching change is
    doing something real on this hardware.

    Happy to run any specific command sequence against this unit if that would help
    narrow down what changed. If whoever implemented #506 can share their firmware
    version, I can compare against mine.

  2. johnsideserf commented on Sep 7, 2026

    @johnsideserf
    Author

    Resolved on my end, and my diagnosis above was wrong. A power cycle of the
    dongle fixed it.

    I unplugged the dongle and plugged it back in. Immediately afterwards:

    $ headsetcontrol -b -m -o json
          "battery": { "status": "BATTERY_AVAILABLE", "level": 51 },
          "chatmix": 64

    Replaying the same 21-packet sequence against /dev/hidraw4 now returns real
    RACE frames instead of empty ones:

    status[0] battery  07 10 80 05 5b 03 00 d6 0c 00 05 5d 05 00 d6 0c 00 00 33 00
                                ^^ 5b response magic       ^^^^^^^^^^^ marker  ^^ 0x33 = 51
    status[1] mic       byte12 = 0xff  (unmuted)
    status[2] eq        byte12 = 0x04
    status[5] sidetone  byte12 = 0x00
    

    So the packets, the framing, the timing and the parsing in
    audeze_maxwell2.hpp are all correct.
    Nothing in the implementation needs
    changing, and my suggestion of a firmware revision difference was unfounded.
    Sorry for the noise.

    What the bug actually looks like

    The dongle can enter a state where it:

    • enumerates normally
    • plays audio perfectly (48kHz/24-bit throughout)
    • accepts and applies setting writes — sidetone changes are audible
    • answers every status query with an empty frame, indefinitely

    and stays there until physically replugged. Everything in my original report
    was accurately observed; I just drew the wrong conclusion from it. The empty
    07 00 80 00 00... replies, the absent 0x5b, the silence across all three
    transports, the failed routing-byte sweep — all real, all symptoms of the stuck
    state rather than of an unsupported protocol.

    I do not know what triggers it. The dongle had been plugged in for a long
    uptime before I started. If other Audeze reports share this shape — audio fine,
    writes fine, battery permanently unavailable — asking whether a replug clears it
    would be a cheap first triage step, and may well apply to #460 too.

    Still worth considering: the write-success check

    The secondary point in my original report stands on its own, and this episode is
    the argument for it. sendGetInputReport() accepts 62 zero bytes as success, so
    throughout the stuck state every setter reported "status": "success" — which
    happened to be true, since writes really were applying, but the check could not
    have told the difference. Rejecting a zero-length reply, or validating that the
    response carries 0x5b, would let headsetcontrol distinguish "device answered"
    from "device is stuck", and could surface this state directly rather than leaving
    it looking like an unsupported capability.

    Happy for this to be closed, or kept open scoped to that check — your call.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions