Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion python/pybmap/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
0x4021: BoseDevice(0x4021, "atlas", "ProFlight", "headphones", None),
0x4024: BoseDevice(0x4024, "goodyear", "Noise Cancelling Headphones 700", "headphones", None),
0x402B: BoseDevice(0x402B, "beanie", "Hearphones II", "headphones", None),
0x4039: BoseDevice(0x4039, "duran", "QuietComfort 45", "headphones", None),
0x4039: BoseDevice(0x4039, "duran", "QuietComfort 45", "headphones", "qc45"),
0x4066: BoseDevice(0x4066, "lonestarr", "QuietComfort Ultra Headphones", "headphones", None),
0x4075: BoseDevice(0x4075, "prince", "QuietComfort Headphones", "headphones", None),
0x4082: BoseDevice(0x4082, "wolverine", "QuietComfort Ultra Headphones (2nd Gen)", "headphones", "qc_ultra2"),
Expand Down
22 changes: 19 additions & 3 deletions python/pybmap/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,12 +286,28 @@ def set_cnc(self, level):
"""Set noise cancellation level (0-10).

Scale is inverted: 0 = maximum ANC (blocks most outside sounds),
10 = most ambient pass-through. The effect is only audible when
anc_toggle=on AND wind_block=off — wind block masks CNC changes.
10 = most ambient pass-through.

Uses [31.10] AudioSettings if available (QC Ultra 2), otherwise
modifies the current editable mode via [31.6] ModeConfig SETGET
and switches to it (QC45).
"""
if not 0 <= level <= 10:
raise ValueError("CNC level must be 0-10, got %d" % level)
self._update_audio_settings(cnc_level=level)
if self.has_feature("audio_settings"):
self._update_audio_settings(cnc_level=level)
elif self.has_feature("mode_config"):
self._set_cnc_via_mode_config(level)
else:
raise BmapError("Device does not support CNC level control")

def _set_cnc_via_mode_config(self, level):
"""Set CNC by writing to an editable mode and switching to it."""
slot, config = self._ensure_editable_profile()
self._write_mode_from_config(slot, config, cnc_level=level)
current = self.mode_idx()
if current != slot:
self._start("current_mode", bytes([slot, 0]))

def set_anc(self, enabled):
"""Toggle Active Noise Cancellation on/off (bool)."""
Expand Down
3 changes: 3 additions & 0 deletions python/pybmap/devices/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@

from . import qc_ultra2
from . import qc35
from . import qc45

# Registry of supported devices keyed by type string.
DEVICES = {
"qc_ultra2": qc_ultra2,
"qc35": qc35,
"qc45": qc45,
}

# Product ID -> device type (for auto-detection after connecting).
PRODUCT_IDS = {
0x4082: "qc_ultra2",
0x4039: "qc45",
# TODO: add QC35 product ID once verified
}

Expand Down
77 changes: 77 additions & 0 deletions python/pybmap/devices/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,80 @@ def build_mode_config_40(mode_idx, name, cnc_level=0, auto_cnc=False,
payload.append(1 if wind_block else 0)
payload.append(1 if anc_toggle else 0)
return bytes(payload)


def parse_mode_config_47(payload):
"""Parse ModeConfig STATUS (47 bytes) — QC45 / CSR8670 firmware.

STATUS layout (47 bytes, no ancToggle vs QC Ultra 2's 48):
[0] modeIndex
[1:3] voicePrompt
[3:6] flags: [3]=editable, [4]=configured, [5]=unknown
[6:38] modeName (32 bytes)
[38:40] unknown
[40:42] unknown
[42] cncLevel
[43] autoCNC
[44] spatial
[45] windBlock
[46] unknown
"""
if len(payload) < 6:
return None

mode_idx = payload[0]
prompt_b1, prompt_b2 = payload[1], payload[2]
prompt_name = PROMPTS.get((prompt_b1, prompt_b2), "(%d,%d)" % (prompt_b1, prompt_b2))

if len(payload) >= 47:
editable = bool(payload[3])
configured = bool(payload[4])
flags = "%02x %02x %02x" % (payload[3], payload[4], payload[5])
name = payload[6:38].split(b"\x00", 1)[0].decode("utf-8", errors="replace")
return ModeConfig(
mode_idx=mode_idx, prompt=prompt_name,
prompt_bytes=(prompt_b1, prompt_b2), name=name,
cnc_level=payload[42], auto_cnc=bool(payload[43]),
spatial=payload[44], wind_block=bool(payload[45]),
anc_toggle=False,
editable=editable, configured=configured, flags=flags, raw=payload,
)
elif len(payload) >= 39:
name = payload[3:35].split(b"\x00", 1)[0].decode("utf-8", errors="replace")
return ModeConfig(
mode_idx=mode_idx, prompt=prompt_name,
prompt_bytes=(prompt_b1, prompt_b2), name=name,
cnc_level=payload[35], auto_cnc=bool(payload[36]),
spatial=payload[37], wind_block=bool(payload[38]),
anc_toggle=False,
editable=True, configured=True, flags="", raw=payload,
)
else:
name = (payload[3:35] if len(payload) >= 35 else payload[3:])
name = name.split(b"\x00", 1)[0].decode("utf-8", errors="replace")
return ModeConfig(
mode_idx=mode_idx, prompt=prompt_name,
prompt_bytes=(prompt_b1, prompt_b2), name=name,
cnc_level=0, auto_cnc=False, spatial=0,
wind_block=False, anc_toggle=False,
editable=False, configured=False, flags="", raw=payload,
)


def build_mode_config_39(mode_idx, name, cnc_level=0, auto_cnc=False,
spatial=0, wind_block=0,
prompt_b1=0, prompt_b2=0):
"""Build 39-byte ModeConfig SETGET payload — QC45 / CSR8670 firmware.

Same as QC Ultra 2's 40-byte format but without the ancToggle byte.
"""
payload = bytearray()
payload.append(mode_idx)
payload.append(prompt_b1)
payload.append(prompt_b2)
payload.extend(encode_mode_name(name))
payload.append(cnc_level)
payload.append(1 if auto_cnc else 0)
payload.append(spatial)
payload.append(1 if wind_block else 0)
return bytes(payload)
120 changes: 120 additions & 0 deletions python/pybmap/devices/qc45.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Bose QC45 device configuration.

Codename "duran", product ID 0x4039, firmware 4.0.4.
RFCOMM channel 8, requires INIT_PACKET (0,1) before responding.

Capabilities verified against real hardware:
- Battery, firmware, serial, product name: GET works
- Device name, sidetone, voice prompts: GET + SETGET works
- Buttons: GET + SETGET works (Shortcut button with SwitchDevice action)
- CNC noise cancellation [1.5]: GET-only, SETGET requires auth
- CNC via AudioModes [31.6]: SETGET works on editable modes (39-byte payload)
Editable modes 2-3 accept cnc_level 0-10 via ModeConfig SETGET
- Mode switching [31.3]: START works (silent or with voice prompt)
- EQ [1.7]: GET + SETGET works (3-band: Bass/Mid/Treble, range -10 to +10)
- Multipoint [1.10]: GET works
- Power state [7.4]: GET works
- Pairing mode: START works
- No ANR [1.6], no auto-pause [1.24], no auto-answer [1.27]
- No AudioSettings [31.10] (FuncNotSupp — use ModeConfig [31.6] instead)
"""

from . import parsers

RFCOMM_CHANNEL = 8

INIT_PACKET = (0, 1)

DEVICE_INFO = {
"name": "Bose QuietComfort 45",
"codename": "duran",
"platform": "CSR8670",
"product_id": 0x4039,
"variant": 0x01,
}

FEATURES = {
"battery": {
"addr": (2, 2),
"parser": parsers.parse_battery,
},
"firmware": {
"addr": (0, 5),
"parser": parsers.parse_firmware,
},
"product_name": {
"addr": (1, 2),
"parser": parsers.parse_product_name,
"builder": lambda name: name.encode("utf-8"),
},
"voice_prompts": {
"addr": (1, 3),
"parser": parsers.parse_voice_prompts,
"builder": parsers.build_voice_prompts,
},
"cnc": {
"addr": (1, 5),
"parser": parsers.parse_cnc,
},
"eq": {
"addr": (1, 7),
"parser": parsers.parse_eq,
"builder": parsers.build_eq_band,
},
"buttons": {
"addr": (1, 9),
"parser": parsers.parse_buttons,
"builder": parsers.build_buttons,
},
"multipoint": {
"addr": (1, 10),
"parser": parsers.parse_multipoint,
"builder": parsers.build_toggle,
},
"sidetone": {
"addr": (1, 11),
"parser": parsers.parse_sidetone,
"builder": parsers.build_sidetone,
},
"pairing": {
"addr": (4, 8),
},
# AudioModes block (31) — 39-byte SETGET, 47-byte STATUS
"get_all_modes": {
"addr": (31, 1),
},
"current_mode": {
"addr": (31, 3),
},
"default_mode": {
"addr": (31, 4),
},
"mode_config": {
"addr": (31, 6),
"parser": parsers.parse_mode_config_47,
"builder": parsers.build_mode_config_39,
},
"favorites": {
"addr": (31, 8),
},
}

PRESET_MODES = {
"quiet": {"idx": 0, "description": "Quiet — full ANC (cnc=0)"},
"aware": {"idx": 1, "description": "Aware — transparency (cnc=10)"},
}

MODE_BY_IDX = {m["idx"]: name for name, m in PRESET_MODES.items()}

EDITABLE_SLOTS = [2, 3]

STATUS_OFFSETS = {
"prompt_b1": 1,
"prompt_b2": 2,
"editable": 3,
"configured": 4,
"cnc_level": 42,
"auto_cnc": 43,
"spatial": 44,
"wind_block": 45,
}
Loading