Conversation
Firmware sets the ModeConfig 'configured' bit on first write and never clears it, so a deleted slot kept it and find_free_slot skipped the slot forever: "No free profile slot available" with two blank slots on screen. Python and Rust required !configured; C++ already tested the name sentinel only. Align all three on the name test. Profile lookup by name returned the first match, which is the preset when a custom profile shares its name. That made such a profile impossible to edit or delete. Prefer an editable slot, fall back to the preset so the existing "cannot delete preset" error still fires. Mark QC45 verified on hardware and record two firmware behaviours found while testing: wind block and CNC level are mutually exclusive in a ModeConfig write, and a 40-byte payload is rejected. Co-authored-by: Gabriel Pita <dvpita@gmail.com>
After the headset drops and reconnects, responses queued before the drop are still in the socket, so every read returns the previous request's answer. The payload is then handed to the wrong parser and surfaces as plausible looking data rather than an error: a device name of control bytes, a battery of 225, no current mode. Writes still appear to succeed. Measured on a QC45 by sending GETs to 2.2, 0.5 and 1.2 in a loop across a reconnect. 19 of 19 mismatches were exactly one response behind: 0.5 answered by 2.2, 1.2 answered by 0.5, 2.2 answered by 1.2. parse_response already returns the address, so compare it with the request and raise instead of parsing. A matching response is unaffected. set_eq talked to the transport directly and discarded the response, so it skipped this check and swallowed device errors. Route it through setget like every other setter. Co-authored-by: Gabriel Pita <dvpita@gmail.com>
jpita
marked this pull request as ready for review
September 11, 2026 14:43
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
find_free_slotrequired!configured, and firmware never clears that bit.4.0.4-4360+de6a887. Two firmware behaviours recorded.Changes
Free slot detection. Firmware sets
configuredon first write and never clears it, so a deleted slot kept the bit and the slot was skipped forever. Symptom:No free profile slot availablewith two blank slots listed.C++ already tested the name sentinel only (
connection.h:392). Python and Rust also required!configured. All three now test the name.Profile lookup.
delete_profileandupdate_profiletook the first name match. With a preset and a custom profile both namedAware, that is the preset, and the custom one becomes unreachable. Now prefers an editable slot and falls back to the preset, so the existing "cannot delete preset" error still fires for a preset-only match.Response address check.
get,setgetandstartparsed whatever came back without checking it came from the address they asked for. After the headset drops and reconnects, responses queued before the drop are still in the socket and every read returns the previous request's answer, so the payload reaches the wrong parser.It does not look like a failure. It looks like this:
Writes still report success, so a long lived connection can sit in this state.
Measured by sending GETs to
2.2,0.5and1.2in a loop across a reconnect on a QC45. Every mismatch was exactly one response behind, 19 out of 19:0.5firmware2.21.2name0.52.2battery1.2parse_responsealready returnsfblockandfunc, so this is a comparison against the request. A matching response is unaffected. The new error subclasses the connection error so callers that already retry on connection loss pick it up.set_eqtalked to the transport directly and discarded the response, so it skipped the check and swallowed device errors. It now goes throughsetgetlike every other setter.QC45 status. Table row and the
is_supported(0x4039)example in the README were stale; it returnsTrue. Thesupported_devices()example was also missinglandoandserena.Device Impact
duran, 0x4039): moved from inferred to verifiedSlot-reuse and lookup fixes apply to every device with editable slots. The address check applies to every device.
QC45 hardware report
Firmware
4.0.4-4360+de6a887, RFCOMM channel 8, init packet(0,1)required, macOS.Working: battery, firmware, device name read/write, status, EQ read/write
[1.7], mode switching[31.3], profile create/rename/delete[31.6], wind block, sidetone, voice prompts, button mapping read[1.9].Not supported: ANR
[1.6], AudioSettings[31.10], auto-pause, auto-answer. Matches the module docstring.Multipoint
[1.10]reads correctly but takes no writes. Every SETGET is accepted with no error and the stored value never moves:0x00(whatbuild_togglesends for off)0x060x02(bit 1 only)0x060x04(off, other bits preserved)0x060x06(unchanged)0x06Worth a look separately:
parse_multipointreads bit 1 (0x02) whilebuild_togglewrites a plain 0 or 1, so the two disagree on encoding. Sending the correct bit pattern did not help on this firmware, so I have left both alone rather than change an encoding I cannot verify on a device where the field accepts writes.set_cncdocstring says wind block masks CNC changes. On QC45 the value is not masked, it is never stored. Five paired ModeConfig writes, identical structure, one byte different:[42][46]cnc=6 wind=off06000600cnc=8 wind=off08000800cnc=5 wind=on05010001cnc=4 wind=on04010001cnc=3 wind=on03010001Consistent with QC35, where wind is an ANR
[1.6]enum value rather than a separate flag.A 40-byte ModeConfig payload with a capability byte before cnc is rejected with
ERROR: Length (01). 39 is correct.Preset slots 0 and 1 are read-only; only slots 2 and 3 accept a CNC level.
Checklist
make test): Python 197 passed / 18 skipped (12 new), Rust 76 passed, C++ 63 passed.Question
I built a local web UI on top of
pybmapwhile testing this: a small HTTP server holding the RFCOMM connection plus one HTML page, covering modes, CNC, EQ, profile slots, settings, button mapping and a raw packet console. It also reads the device back after every write, which is how the wind/CNC behaviour above surfaced.Two notes if it is of interest: a browser cannot reach these headphones directly (Web Bluetooth is BLE GATT only), and on macOS IOBluetooth refuses to open RFCOMM from a worker thread, so Bluetooth work has to stay on the main thread.
Happy to contribute it here, keep it as a separate repo that depends on
pybmap, or drop it. Your call.