Skip to content

fix: profile slot reuse, profile name lookup, and response address validation - #35

Open
jpita wants to merge 2 commits into
aaronsb:mainfrom
jpita:fix/profile-slot-reuse-and-qc45-verification
Open

jpita wants to merge 2 commits into
aaronsb:mainfrom
jpita:fix/profile-slot-reuse-and-qc45-verification

Conversation

@jpita

@jpita jpita commented Sep 11, 2026

Copy link
Copy Markdown

Summary

  • Cleared profile slots were never reusable: find_free_slot required !configured, and firmware never clears that bit.
  • Profile lookup by name returned the preset first, so a custom profile sharing a preset name could not be edited or deleted. Fixes Profile with the same name as a preset can't be removed #29.
  • A response from the wrong address was parsed as if it were the right one, turning a reconnect into plausible looking garbage instead of an error.
  • QC45 verified on hardware, firmware 4.0.4-4360+de6a887. Two firmware behaviours recorded.

Changes

Free slot detection. Firmware sets configured on first write and never clears it, so a deleted slot kept the bit and the slot was skipped forever. Symptom: No free profile slot available with 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.

$ bosectl profiles
   2                    cnc=0
   3                    cnc=0
$ bosectl profile set Gym cnc=6
Error: No free profile slot available

Profile lookup. delete_profile and update_profile took the first name match. With a preset and a custom profile both named Aware, 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, setget and start parsed 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:

name    : '\n\xef\xbf\xbd\x00\xef\xbf\xbd...'
firmware: ''
battery : 225
mode    : '' idx None

Writes still report success, so a long lived connection can sit in this state.

Measured by sending GETs to 2.2, 0.5 and 1.2 in a loop across a reconnect on a QC45. Every mismatch was exactly one response behind, 19 out of 19:

asked answered by payload actually returned
0.5 firmware 2.2 battery
1.2 name 0.5 firmware
2.2 battery 1.2 name

parse_response already returns fblock and func, 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_eq talked to the transport directly and discarded the response, so it skipped the check and swallowed device errors. It now goes through setget like every other setter.

QC45 status. Table row and the is_supported(0x4039) example in the README were stale; it returns True. The supported_devices() example was also missing lando and serena.

Device Impact

  • QC Ultra 2
  • QC35
  • New device: QuietComfort 45 (duran, 0x4039): moved from inferred to verified

Slot-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:

SETGET payload stored after
0x00 (what build_toggle sends for off) 0x06
0x02 (bit 1 only) 0x06
0x04 (off, other bits preserved) 0x06
0x06 (unchanged) 0x06

Worth a look separately: parse_multipoint reads bit 1 (0x02) while build_toggle writes 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_cnc docstring 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:

sent payload cnc payload wind stored [42] stored [46]
cnc=6 wind=off 06 00 06 00
cnc=8 wind=off 08 00 08 00
cnc=5 wind=on 05 01 00 01
cnc=4 wind=on 04 01 00 01
cnc=3 wind=on 03 01 00 01

Consistent 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

  • Python, Rust, and C++ implementations stay in sync
  • Tests pass (make test): Python 197 passed / 18 skipped (12 new), Rust 76 passed, C++ 63 passed.
  • Protocol notes updated: QC45 module docstring
  • Architecture docs updated (not applicable)

Question

I built a local web UI on top of pybmap while 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.

Claude and others added 2 commits September 11, 2026 10:59
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 jpita changed the title fix: reuse cleared profile slots and prefer custom profiles by name fix: profile slot reuse, profile name lookup, and response address validation Sep 11, 2026
@jpita
jpita marked this pull request as ready for review September 11, 2026 14:43
@jpita
jpita requested a review from aaronsb as a code owner September 11, 2026 14:43
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.

Profile with the same name as a preset can't be removed

1 participant