diff --git a/completion/_bosectl b/completion/_bosectl new file mode 100644 index 0000000..daa752f --- /dev/null +++ b/completion/_bosectl @@ -0,0 +1,311 @@ +#compdef bosectl + +# --------------------------------------------------------------------------- +# bosectl — Zsh tab completion for Bose headphone control over BMAP +# --------------------------------------------------------------------------- +# Install: copy to a directory in $fpath, e.g.: +# mkdir -p ~/.zsh/completions +# cp _bosectl ~/.zsh/completions/ +# echo 'fpath=(~/.zsh/completions $fpath)' >> ~/.zshrc +# echo 'autoload -Uz compinit && compinit' >> ~/.zshrc +# +# Or for a single session: autoload -Uz _bosectl && compdef _bosectl bosectl +# --------------------------------------------------------------------------- + +# ── Preset mode names (all known devices) ──────────────────────────────────── + +_preset_modes=(quiet aware immersion cinema) + +# ── Top-level commands ─────────────────────────────────────────────────────── + +_bosectl_commands() { + local -a commands + commands=( + # Modes + 'quiet:Quiet — full ANC' + 'aware:Aware — transparency' + 'immersion:Immersion — spatial audio with head tracking' + 'cinema:Cinema — spatial audio with fixed stage' + + # Profiles + 'profiles:List all audio profiles' + 'profile:Create, update, or delete a custom profile' + 'switch:Switch to a profile by name' + + # Settings + 'cnc:Noise cancellation level (0-10)' + 'anc:Toggle Active Noise Cancellation' + 'wind:Toggle Wind Block' + 'eq:3-band equalizer (-10 to +10 each)' + 'spatial:Spatial audio mode (off, room, head)' + 'name:Get or set device Bluetooth name' + 'anr:Active Noise Reduction mode (QC35: off, high, wind, low)' + 'sidetone:Sidetone level (off, low, medium, high)' + 'multipoint:Toggle multipoint connection' + 'autopause:Toggle auto play/pause on ear removal' + 'autoanswer:Toggle auto-answer calls' + 'prompts:Toggle voice prompts' + + # Routing + 'source:Show active audio source' + 'route:Switch active device by MAC address' + + # Control + 'pair:Enter Bluetooth pairing mode' + 'off:Power off headphones' + + # Info + 'status:Show all settings' + 'battery:Battery percentage' + 'current:Current mode name' + 'buttons:Show or remap button configuration' + + # Debug + 'dump:Dump all AudioModes state' + 'raw:Send raw BMAP packet (hex bytes)' + ) + _describe -t commands 'bosectl command' commands +} + +# ── Sub-commands ───────────────────────────────────────────────────────────── + +_bosectl_profile() { + local -a subcmds + subcmds=( + 'set:Create or update a custom profile' + 'create:Alias for set' + 'add:Alias for set' + 'rm:Delete a custom profile' + 'delete:Alias for rm' + 'del:Alias for rm' + ) + _describe -t profile-cmds 'profile subcommand' subcmds +} + +_bosectl_buttons() { + local -a subcmds + subcmds=( + 'set:Remap a button action' + ) + _describe -t button-cmds 'buttons subcommand' subcmds +} + +# ── Value completions ──────────────────────────────────────────────────────── + +_bosectl_bool() { + local -a vals + vals=('on' 'off') + _describe -t bool 'value' vals +} + +_bosectl_cnc() { + local -a levels + levels=({0..10}) + _describe -t cnc 'CNC level (0=max ANC, 10=most ambient)' levels +} + +_bosectl_eq() { + local -a eq_cmds + eq_cmds=('flat:Reset all bands to zero' 'get:Show current EQ') + _describe -t eq-cmds 'eq command' eq_cmds +} + +_bosectl_spatial() { + local -a modes + modes=('off' 'room' 'head') + _describe -t spatial 'spatial audio mode' modes +} + +_bosectl_sidetone() { + local -a levels + levels=('off' 'low' 'medium' 'high') + _describe -t sidetone 'sidetone level' levels +} + +_bosectl_anr() { + local -a levels + levels=('off' 'high' 'wind' 'low') + _describe -t anr 'ANR level' levels +} + +_bosectl_buttons_action() { + local -a actions + actions=( + 'NotConfigured:No action assigned' + 'VPA:Voice assistant' + 'ANC:Toggle noise cancellation' + 'BatteryLevel:Speak battery level' + 'PlayPause:Play/pause media' + 'IncreaseCNC:Increase noise cancellation' + 'DecreaseCNC:Decrease noise cancellation' + 'ToggleWakeWord:Toggle voice assistant wake word' + 'SwitchDevice:Switch connected device' + 'ConversationMode:Quick conversation mode' + 'TrackForward:Skip to next track' + 'TrackBack:Go to previous track' + 'FetchNotifications:Read notifications' + 'WindMode:Toggle wind block' + 'Disabled:Disable the button' + 'ClientInteraction:Client app interaction' + 'SpotifyGo:Spotify quick play' + 'ModesCarousel:Cycle through modes' + 'SpatialAudioMode:Toggle spatial audio' + 'LineInSwitch:Switch to line-in' + 'Linking:Link/unlink devices' + ) + _describe -t actions 'button action' actions +} + +# ── Options for profile set ────────────────────────────────────────────────── + +_bosectl_profile_opts() { + local -a opts + opts=( + 'cnc=:CNC level (0-10):_bosectl_cnc' + 'spatial=:Spatial audio mode:(off room head)' + 'wind=:Wind block:(on off)' + 'anc=:ANC toggle:(on off)' + 'name=:Profile display name:' + ) + # Only complete options if we're past the profile name (arg position >= 3) + # For simplicity, always offer options after the first argument + _describe -t profile-opts 'profile option' opts +} + +# ── Main completion function ───────────────────────────────────────────────── + +_bosectl() { + local context state state_descr line + typeset -A opt_args + + _arguments -C \ + '1: :_bosectl_commands' \ + '*:: :->args' + + case $state in + args) + local cmd="${words[1]}" + local sub="${words[2]}" + + case $cmd in + # ── Preset modes: no arguments ── + quiet|aware|immersion|cinema) + # No arguments + ;; + + # ── Read-only commands: no arguments ── + status|battery|current|profiles|source|pair|off|dump) + ;; + + # ── Commands that accept an optional boolean ── + anc|wind|multipoint|autopause|autoanswer|prompts) + [[ $CURRENT -eq 2 ]] && _bosectl_bool + ;; + + # ── CNC: level 0-10 ── + cnc) + [[ $CURRENT -eq 2 ]] && _bosectl_cnc + ;; + + # ── EQ: bass mid treble or flat/get ── + eq) + case $CURRENT in + 2) _bosectl_eq ;; + + 3) _message -e numbers 'bass (-10..+10)' + _values 'bass' {-10..10} ;; + 4) _message -e numbers 'mid (-10..+10)' + _values 'mid' {-10..10} ;; + 5) _message -e numbers 'treble (-10..+10)' + _values 'treble' {-10..10} ;; + esac + ;; + + # ── Spatial: mode name ── + spatial) + [[ $CURRENT -eq 2 ]] && _bosectl_spatial + ;; + + # ── Sidetone: level name ── + sidetone) + [[ $CURRENT -eq 2 ]] && _bosectl_sidetone + ;; + + # ── ANR (QC35): level name ── + anr) + [[ $CURRENT -eq 2 ]] && _bosectl_anr + ;; + + # ── Name: free-form text ── + name) + # No completion — any text is valid + ;; + + # ── Route: MAC address ── + route) + [[ $CURRENT -eq 2 ]] && _message -e mac 'MAC address (XX:XX:XX:XX:XX:XX)' + ;; + + # ── Switch: mode name ── + switch) + [[ $CURRENT -eq 2 ]] && _describe -t modes 'mode name' _preset_modes + ;; + + # ── Buttons: optional 'set' subcommand ── + buttons) + case $CURRENT in + 2) _bosectl_buttons ;; + 3) _bosectl_buttons_action ;; + esac + ;; + + # ── Profile: subcommands ── + profile) + case $CURRENT in + 2) _bosectl_profile ;; + 3) + case $sub in + set|create|add) + _message -e name 'profile name' + ;; + rm|delete|del) + _message -e name 'profile name to delete' + ;; + *) + # Treat as profile name for shorthand (profile ...) + _message -e name 'profile name' + ;; + esac + ;; + 4) + case $sub in + set|create|add) + _bosectl_profile_opts + ;; + esac + ;; + *) + case $sub in + set|create|add) + _bosectl_profile_opts + ;; + esac + ;; + esac + ;; + + # ── Raw: hex bytes ── + raw) + _message -e hex 'hex bytes (e.g. "01 05 02 02 08 01")' + ;; + + # ── Unknown: offer top-level commands again ── + *) + _bosectl_commands + ;; + esac + ;; + esac +} + +_bosectl "$@" diff --git a/python/pybmap/catalog.py b/python/pybmap/catalog.py index a1a8109..249945a 100644 --- a/python/pybmap/catalog.py +++ b/python/pybmap/catalog.py @@ -54,7 +54,7 @@ 0x4018: BoseDevice(0x4018, "levi", "SoundSport Free", "earbuds", None), 0x402C: BoseDevice(0x402C, "celine", "Frames", "earbuds", None), 0x402D: BoseDevice(0x402D, "revel", "Sport Earbuds", "earbuds", None), - 0x402F: BoseDevice(0x402F, "lando", "QuietComfort Earbuds", "earbuds", None), + 0x402F: BoseDevice(0x402F, "lando", "QuietComfort Earbuds", "earbuds", "qc_earbuds"), 0x403A: BoseDevice(0x403A, "gwen", "Sport Open Earbuds", "earbuds", None), 0x404C: BoseDevice(0x404C, "celine_ii", "Frames (2nd Gen)", "earbuds", None), 0x4060: BoseDevice(0x4060, "olivia", "Frames Tempo", "earbuds", None), diff --git a/python/pybmap/connection.py b/python/pybmap/connection.py index 97a985d..69a28ed 100644 --- a/python/pybmap/connection.py +++ b/python/pybmap/connection.py @@ -279,7 +279,7 @@ def set_mode(self, name, announce=False): idx = found resp = self._start("current_mode", bytes([idx, 1 if announce else 0])) - if resp and resp.op != OP_RESULT: + if resp and resp.op not in (OP_RESULT, OP_PROCESSING): raise BmapDeviceError("Mode switch failed: %s" % fmt_response(resp)) def set_cnc(self, level): @@ -288,10 +288,18 @@ def set_cnc(self, level): 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. + + Uses direct [1.5] SETGET on devices without AudioSettingsConfig + (QC Earbuds), or [31.10] AudioSettingsConfig on newer devices. """ if not 0 <= level <= 10: raise ValueError("CNC level must be 0-10, got %d" % level) - self._update_audio_settings(cnc_level=level) + # QC Earbuds (and similar): use direct [1.5] CNC SETGET when available + feat = self._feature("cnc") + if feat.get("builder") and not self.has_feature("audio_settings"): + self._setget("cnc", feat["builder"](level)) + else: + self._update_audio_settings(cnc_level=level) def set_anc(self, enabled): """Toggle Active Noise Cancellation on/off (bool).""" diff --git a/python/pybmap/devices/__init__.py b/python/pybmap/devices/__init__.py index 57f8052..615fdec 100644 --- a/python/pybmap/devices/__init__.py +++ b/python/pybmap/devices/__init__.py @@ -2,16 +2,19 @@ from . import qc_ultra2 from . import qc35 +from . import qc_earbuds # Registry of supported devices keyed by type string. DEVICES = { "qc_ultra2": qc_ultra2, "qc35": qc35, + "qc_earbuds": qc_earbuds, } # Product ID -> device type (for auto-detection after connecting). PRODUCT_IDS = { 0x4082: "qc_ultra2", + 0x402F: "qc_earbuds", # TODO: add QC35 product ID once verified } diff --git a/python/pybmap/devices/parsers.py b/python/pybmap/devices/parsers.py index 8db728c..5748e26 100644 --- a/python/pybmap/devices/parsers.py +++ b/python/pybmap/devices/parsers.py @@ -335,6 +335,56 @@ def build_audio_settings(cnc_level=0, auto_cnc=False, spatial=0, ]) +def build_cnc(level): + """Build CNC SETGET payload for [1.5] — QC Earbuds / direct CNC path. + + Payload: [level, enabled_flag]. Level 0-10. Enabled=1. + """ + return bytes([level & 0xFF, 1]) + + +def parse_mode_config_44(payload): + """Parse ModeConfig STATUS (44 bytes) — QC Earbuds (1st Gen) firmware. + + STATUS layout: + [0] modeIndex + [1:3] voicePrompt + [3:6] flags: [3]=editable, [4]=configured, [5]=unknown + [6:38] modeName (32 bytes) + [38:44] config tail (6 bytes, format varies; largely 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) >= 44: + 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") + # Tail bytes 38-43: format is device-specific and SETGET is + # non-functional on this firmware, so we don't parse them deeply. + 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=editable, configured=configured, flags=flags, raw=payload, + ) + else: + name = payload[6:].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_40(mode_idx, name, cnc_level=0, auto_cnc=False, spatial=0, wind_block=1, anc_toggle=1, prompt_b1=0, prompt_b2=0): diff --git a/python/pybmap/devices/qc_earbuds.py b/python/pybmap/devices/qc_earbuds.py new file mode 100644 index 0000000..17c220c --- /dev/null +++ b/python/pybmap/devices/qc_earbuds.py @@ -0,0 +1,126 @@ +"""Bose QC Earbuds (1st Gen) device configuration. + +Codename "lando", firmware 2.0.7. +BMAP over RFCOMM channel 8. + +Key differences from QC Ultra 2 (wolverine): + - Only 4 modes (0-3), no custom profile slots (4-10 are invalid) + - ModeConfig [31.6] SETGET is non-functional (payload format unknown; + echo-back fails with Length error). Custom profiles are not possible. + - No AudioSettingsConfig [31.10] — uses direct CNC [1.5] SETGET instead + - No AutoPlayPause, AutoAnswer (not applicable to earbuds) + - No spatial audio or wind block settings + - ModeConfig GET responses are 44 bytes (not 48) + +Auth notes: + - GET (op 1) works on all blocks without auth. + - SETGET on Settings [1.x] works without auth. + - START on AudioModes [31.x] and Control [7.x] works without auth. + - SETGET on ModeConfig [31.6] is non-functional (Length error). +""" + +from . import parsers + +RFCOMM_CHANNEL = 8 + +DEVICE_INFO = { + "name": "Bose QC Earbuds", + "codename": "lando", + "platform": "QCC-384", + "product_id": 0x402F, + "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, + }, + "voice_prompts": { + "addr": (1, 3), + "parser": parsers.parse_voice_prompts, + "builder": parsers.build_voice_prompts, + }, + "cnc": { + # Direct CNC SETGET on [1.5] — simpler than the QC Ultra 2's + # [31.10] AudioSettingsConfig path. Payload: [level, enabled]. + "addr": (1, 5), + "parser": parsers.parse_cnc, + "builder": parsers.build_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), + }, + "routing": { + "addr": (4, 12), + "builder": parsers.build_routing, + }, + "source": { + "addr": (5, 1), + "parser": parsers.parse_source, + }, + "power": { + "addr": (7, 4), + }, + # AudioModes block (31) — mode switching works, profile editing does not + "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_44, + # No builder — SETGET on [31.6] is non-functional on this firmware + }, + "favorites": { + "addr": (31, 8), + }, +} + +# ── Mode Configuration ─────────────────────────────────────────────────────── +# Only 4 modes exist on this device (0-3). Modes 4+ return InvalidData error. +# ModeConfig SETGET is non-functional — custom profiles cannot be created. + +PRESET_MODES = { + "quiet": {"idx": 0, "description": "Quiet — full ANC"}, + "aware": {"idx": 1, "description": "Aware — transparency"}, +} + +MODE_BY_IDX = {0: "quiet", 1: "aware"} + +EDITABLE_SLOTS = [] # No editable profile slots on this firmware + +STATUS_OFFSETS = {} diff --git a/python/tests/test_catalog.py b/python/tests/test_catalog.py index ce2e3ab..1dbc89e 100644 --- a/python/tests/test_catalog.py +++ b/python/tests/test_catalog.py @@ -30,6 +30,13 @@ def test_qc_ultra2_earbuds(self): assert dev.codename == "edith" assert dev.config == "qc_ultra2" + def test_qc_earbuds(self): + dev = lookup_device(0x402F) + assert dev is not None + assert dev.codename == "lando" + assert dev.name == "QuietComfort Earbuds" + assert dev.config == "qc_earbuds" + def test_unsupported_known(self): dev = lookup_device(0x4024) assert dev is not None @@ -46,6 +53,7 @@ def test_is_supported(self): assert is_supported(0x4062) # edith assert is_supported(0x4020) # baywolf assert is_supported(0x400C) # wolfcastle + assert is_supported(0x402F) # lando (QC Earbuds) def test_not_supported(self): assert not is_supported(0x4024) # NCH 700 @@ -53,7 +61,7 @@ def test_not_supported(self): def test_supported_devices(self): devs = supported_devices() - assert len(devs) >= 4 # wolfcastle, baywolf, edith, wolverine + assert len(devs) >= 5 # wolfcastle, baywolf, edith, wolverine, lando assert all(d.config is not None for d in devs) def test_known_devices(self): diff --git a/python/tests/test_qc_earbuds.py b/python/tests/test_qc_earbuds.py new file mode 100644 index 0000000..f4d68c7 --- /dev/null +++ b/python/tests/test_qc_earbuds.py @@ -0,0 +1,43 @@ +"""Tests for QC Earbuds (1st Gen) device configuration.""" + +from pybmap.devices import qc_earbuds, DEVICES + + +class TestQCEarbudsConfig: + def test_has_device_info(self): + assert "QC Earbuds" in qc_earbuds.DEVICE_INFO["name"] + assert qc_earbuds.DEVICE_INFO["product_id"] == 0x402F + assert qc_earbuds.DEVICE_INFO["codename"] == "lando" + + def test_channel(self): + assert qc_earbuds.RFCOMM_CHANNEL == 8 + + def test_has_key_features(self): + for feat in ["battery", "cnc", "eq", "buttons", "power", + "current_mode", "mode_config", "sidetone"]: + assert feat in qc_earbuds.FEATURES, "Missing: %s" % feat + + def test_no_audio_settings(self): + assert "audio_settings" not in qc_earbuds.FEATURES + + def test_no_anc_wind_spatial(self): + assert "spatial" not in qc_earbuds.FEATURES + + def test_cnc_has_builder(self): + assert qc_earbuds.FEATURES["cnc"].get("builder") is not None + + def test_no_editable_slots(self): + assert len(qc_earbuds.EDITABLE_SLOTS) == 0 + + def test_4_modes_only(self): + # Only modes 0 (quiet) and 1 (aware) have known names. + # Modes 2-3 exist but are unnamed/unconfigured. + assert 0 in qc_earbuds.MODE_BY_IDX + assert 1 in qc_earbuds.MODE_BY_IDX + assert 4 not in qc_earbuds.MODE_BY_IDX + + def test_no_mode_config_builder(self): + assert qc_earbuds.FEATURES["mode_config"].get("builder") is None + + def test_registered(self): + assert "qc_earbuds" in DEVICES