BMAP Library Architecture
August 22, 2026 · View on GitHub
This guide covers the shared architecture of the BMAP protocol libraries (Python, Rust, C++). All three implement the same layered design and feature-dispatch model. Language-specific details are in Language Implementations at the end.
Overview
The BMAP library controls Bose Bluetooth headphones over RFCOMM without the Bose app, cloud accounts, or authentication. It works by speaking the BMAP (Bose Media Application Protocol) binary protocol directly over a Bluetooth serial connection.
graph TD
A[Application Code] --> B[BmapConnection]
B --> C[Device Config]
B --> D[Transport]
D --> E[Protocol Codec]
E --> F[Bluetooth RFCOMM Socket]
C --> G[Parsers & Builders]
style A fill:#f5f5f5,stroke:#333
style B fill:#4a9eff,stroke:#333,color:#fff
style C fill:#ff9f43,stroke:#333,color:#fff
style D fill:#26de81,stroke:#333,color:#fff
style E fill:#a55eea,stroke:#333,color:#fff
style F fill:#778ca3,stroke:#333,color:#fff
style G fill:#ff9f43,stroke:#333,color:#fff
Layers
Protocol Codec
The lowest layer. Encodes and decodes raw BMAP packets on the wire.
Packet format:
Byte 0: fblock — function block ID (e.g. 1=Settings, 2=Status, 31=AudioModes)
Byte 1: func — function ID within the block
Byte 2: flags — operator in low nibble (& 0x0F), upper bits carry
device_id and port_num (typically zero)
Byte 3: length — payload length in bytes
Byte 4+: payload — operation-specific data
Functions:
bmap_packet(fblock, func, operator, payload)— encode a requestparse_response(data)— decode a single response frameparse_all_responses(data)— split concatenated frames (used after drain)encode_mode_name(name)— UTF-8 to 32-byte null-padded buffer
Operators:
| Code | Name | Direction | Description |
|---|---|---|---|
| 0 | SET | Request | Write (requires cloud auth) |
| 1 | GET | Request | Read a value |
| 2 | SETGET | Request | Write + read back (no auth on most blocks) |
| 3 | STATUS | Response | Data response / unsolicited notification |
| 4 | ERROR | Response | Error with code in payload[0] |
| 5 | START | Request | Trigger an action |
| 6 | RESULT | Response | Action completed |
| 7 | PROCESSING | Response | Async operation in progress |
Transport
Manages the Bluetooth RFCOMM socket connection.
sequenceDiagram
participant C as Connection
participant T as Transport
participant D as Device
C->>T: send_recv(packet)
T->>D: socket.send(packet)
T->>T: sleep(200ms)
D->>T: socket.recv(response)
T->>C: return response
Note over C,D: Drain mode (for multi-response commands)
C->>T: send_recv_drain(packet)
T->>D: socket.send(packet)
T->>T: sleep(200ms)
D->>T: recv[0] (3s timeout)
loop Until timeout
T->>T: set 500ms timeout
D-->>T: recv[n] (may timeout)
end
T->>C: return concatenated data
Key behaviors:
- Normal mode: Send packet, wait up to 3 seconds for one response
- Drain mode: Send packet, collect all responses until 500ms silence — used for commands that return multiple STATUS frames (e.g.
GetAll) - 200ms post-send delay: Required by the BMAP protocol between send and first recv
The drain interface differs slightly by language: Python uses a boolean
parameter (send_recv(packet, drain=True)), while Rust and C++ have a
separate method (send_recv_drain(packet)).
Platform support:
| Platform | Python | Rust | C++ |
|---|---|---|---|
| Linux | BlueZ AF_BLUETOOTH socket | BlueZ socket | BlueZ socket |
| macOS | IOBluetooth RFCOMM via PyObjC | compiles; connect returns an error pointing at the Python CLI | same as Rust |
RfcommTransport in Python is bound at import time to either
LinuxRfcommTransport or MacOsRfcommTransport on sys.platform; callers
see one class. Discovery follows the same split (bluetoothctl on Linux,
IOBluetoothDevice.pairedDevices() on macOS).
Device Config
A data-only description of a specific headphone model. No logic — just addresses, parser/builder function references, and capability flags.
graph LR
subgraph DeviceConfig
INFO[Device Info<br/>name, codename, platform]
CHAN[RFCOMM Channel<br/>2 or 8]
INIT[Init Packet<br/>optional]
FEAT[Feature Map<br/>name → addr + parser + builder]
MODES[Preset Modes<br/>name → index]
SLOTS[Editable Slots<br/>profile indices]
end
Each feature entry maps a name to its protocol address and codec functions:
"anr" → {
addr: (1, 6) — fblock 1, function 6
parser: parse_anr — bytes → "high"/"low"/"wind"/"off"
builder: build_anr — "high" → bytes([0x01])
}
Device quirks are expressed as config differences, not code branches:
| Property | QC Ultra 2 | QuietComfort Headphones (prince) | QC35 |
|---|---|---|---|
| RFCOMM channel | 2 | 8 | 8 |
| Init packet | None | None | GET [0.1] required |
| Noise control | CNC [1.5] / live [31.10] | CNC/wind via [31.6] ModeConfig | ANR [1.6] (off/high/wind/low) |
| EQ | 3-band [1.7] | Not verified | Not supported |
| Spatial audio | [31.6] ModeConfig | Field observed in [31.6] | Not supported |
| Mode profiles | 7 editable slots (4-10) | 2 editable slots observed (2-3) | None |
| Sidetone | [1.11] | Not verified | [1.11] |
| Multipoint | [1.10] | Not verified | Not supported |
| Button remap | Shortcut (0x80) | Not verified | Action (0x10) |
BmapConnection
The primary public API. Composes a transport with a device config to provide typed read/write methods. All feature methods use the same dispatch pattern:
sequenceDiagram
participant App as Application
participant Conn as BmapConnection
participant Cfg as DeviceConfig
participant T as Transport
participant P as Protocol
App->>Conn: set_anr("high")
Conn->>Cfg: lookup feature "anr"
Cfg-->>Conn: addr=(1,6), builder=build_anr
Conn->>Conn: payload = build_anr("high") → [0x01]
Conn->>P: bmap_packet(1, 6, SETGET, [0x01])
P-->>Conn: raw bytes [01 06 02 01 01]
Conn->>T: send_recv(raw bytes)
T-->>Conn: response bytes
Conn->>P: parse_response(response)
P-->>Conn: BmapResponse{op=STATUS, payload=[0x01, 0x0b]}
Conn->>Conn: check for ERROR
Conn-->>App: ok
Read pattern (GET):
battery() → _get("battery") → lookup addr → send GET → parse response → apply parser
Write pattern (SETGET):
set_anr("high") → lookup addr + builder → build payload → send SETGET → check for errors
Action pattern (START + drain):
modes() → _start_drain("get_all_modes") → send START → drain all STATUS → parse each → collect
Parsers & Builders
Pure functions that convert between wire bytes and typed values. Shared across device configs — a parser is referenced by name in the feature map, not inherited.
Parsers decode response payloads:
parse_battery([0x50, 0xff, 0xff, 0x00]) → 80
parse_cnc([0x0b, 0x07, 0x03]) → (current=7, max=10)
parse_anr([0x01, 0x0b]) → "high"
parse_buttons([0x10, 0x04, 0x01, 0x07]) → ButtonMapping{Action, single_press, VPA}
Builders encode request payloads:
build_anr("high") → bytes([0x01])
build_sidetone(2) → bytes([0x01, 0x02])
build_buttons(0x10, 4, 2) → bytes([0x10, 0x04, 0x02])
build_mode_config_40(5, "Custom", cnc_level=8) → 40-byte payload
build_mode_config_39(3, "Music", cnc_level=5) → 39-byte payload
Builders accept both integer IDs and string names where applicable
(e.g. build_buttons("Action", "single_press", "ANC")).
Connection Lifecycle
sequenceDiagram
participant App as Application
participant Lib as connect()
participant Disc as Discovery
participant T as Transport
participant D as Device
App->>Lib: connect(mac=None)
Lib->>Disc: find_bmap_device()
Disc->>Disc: bluetoothctl devices Paired
Disc->>Disc: Filter by BMAP UUID + audio class
Disc->>Disc: Extract product ID from Modalias
Disc-->>Lib: (mac="4C:87:...", type="qc35")
Lib->>Lib: get_device("qc35") → config
Lib->>T: RfcommTransport(mac, channel=8)
T->>D: RFCOMM connect
D-->>T: connected
Note over Lib,D: QC35 quirk: init packet required
Lib->>T: send_recv(GET [0.1])
D-->>T: STATUS response
Lib-->>App: BmapConnection(transport, config)
Discovery uses bluetoothctl to enumerate paired devices, filtering by:
- Device class contains
audio-headsetoraudio-headphones - SDP records contain BMAP UUID
00000000-deca-fade-deca-deafdecacaff - Modalias product ID maps to a known device type
Connected devices are preferred over paired-but-disconnected.
Error Handling
BMAP errors are returned in ERROR (op 4) responses with an error code
in payload[0]:
| Code | Name | Meaning |
|---|---|---|
| 1 | Length | Payload size wrong |
| 3 | FblockNotSupp | Function block doesn't exist |
| 4 | FuncNotSupp | Function doesn't exist in block |
| 5 | OpNotSupp | Requires cloud-mediated ECDH auth |
| 6 | InvalidData | Value out of range or wrong format |
| 8 | Runtime | Firmware-level rejection (e.g. preset mode locked) |
| 10 | InvalidState | Pre-requisite not met |
| 15 | InvalidTransition | State machine violation |
| 20 | InsecureTransport | Encrypted link required |
The library surfaces these at appropriate levels:
- Unsupported feature → raised immediately at lookup time (not sent to device)
- Auth error (code 5) → distinct error type for programmatic handling
- Device errors → include the error code and formatted response
Authentication Model
BMAP has two write paths with different auth requirements:
graph TD
GET[GET op=1] -->|Always free| READ[Read any value]
SETGET[SETGET op=2] -->|Free on blocks 1, 31| WRITE[Write + confirm]
SET[SET op=0] -->|Cloud auth required| LOCKED[Rejected without ECDH]
START[START op=5] -->|Free on block 31| ACTION[Trigger action]
style GET fill:#26de81,stroke:#333,color:#fff
style SETGET fill:#26de81,stroke:#333,color:#fff
style START fill:#26de81,stroke:#333,color:#fff
style SET fill:#fc5c65,stroke:#333,color:#fff
style READ fill:#f5f5f5,stroke:#333
style WRITE fill:#f5f5f5,stroke:#333
style LOCKED fill:#f5f5f5,stroke:#333
style ACTION fill:#f5f5f5,stroke:#333
The library uses SETGET (not SET) for all writes and START for mode switching. This gives full control over settings, profiles, and modes without any authentication.
Device Catalog
The catalog module (catalog.py / catalog.rs / catalog.h) is the
single source of truth for all known Bose BMAP devices. It's sourced from
Bose's firmware manifest at downloads.bose.com/lookup.xml.
graph TD
CAT[Device Catalog<br/>known BMAP devices] --> SUP[Supported<br/>config ≠ None]
CAT --> UNSUP[Recognized but Unsupported<br/>config = None]
SUP --> DISC[Discovery<br/>PID → config lookup]
SUP --> CFG[Device Configs<br/>qc_ultra2, qc_prince, qc35]
DISC --> CONN[connect()]
CFG --> CONN
style CAT fill:#ff9f43,stroke:#333,color:#fff
style SUP fill:#26de81,stroke:#333,color:#fff
style UNSUP fill:#778ca3,stroke:#333,color:#fff
style DISC fill:#4a9eff,stroke:#333,color:#fff
style CFG fill:#4a9eff,stroke:#333,color:#fff
style CONN fill:#4a9eff,stroke:#333,color:#fff
Each catalog entry carries:
| Field | Description | Example |
|---|---|---|
product_id | USB PID / Bluetooth Modalias ID | 0x4082 |
codename | Bose internal codename | "wolverine" |
name | Marketing product name | "QuietComfort Ultra Headphones (2nd Gen)" |
category | headphones, earbuds, or speaker | headphones |
config | Library config key, or None | "qc_ultra2" |
Public API (identical across all three libraries):
lookup_device(0x4082) → BoseDevice{wolverine, "QuietComfort Ultra Headphones (2nd Gen)", config="qc_ultra2"}
lookup_device(0x4075) → BoseDevice{prince, "QuietComfort Headphones", config="qc_prince"}
is_supported(0x4024) → False (NCH 700: recognized, no config yet)
supported_devices() → [wolfcastle, baywolf, edith, prince, wolverine]
known_devices() → full catalog
usb_ids(0x4082) → (0x05A7, 0x4082)
modalias(0x4082) → "bluetooth:v05A7p4082d0000"
The USB vendor ID 0x05A7 is shared by all Bose devices. The catalog
is sourced from Bose's BoseProductId registry — the registry's
value field is what the device reports in the Bluetooth Modalias
string. Note that USB DFU PIDs (as listed by projects like bose-dfu)
are a different ID space and should not be conflated with BT
Modalias PIDs.
Discovery uses the catalog to resolve product IDs to config keys. Devices
with config=None are recognized (logged, not errored) but fall back to
a default config since they don't have a tested implementation yet.
Supported Devices
| PID | Codename | Product | Config |
|---|---|---|---|
0x400C | wolfcastle | QuietComfort 35 | qc35 |
0x4020 | baywolf | QuietComfort 35 II | qc35 |
0x4062 | edith | QuietComfort Ultra Earbuds (2nd Gen) | qc_ultra2 |
0x4075 | prince | QuietComfort Headphones | qc_prince |
0x402F | lando | QuietComfort Earbuds | qc_earbuds |
0x4039 | duran | QuietComfort 45 | qc45 (inferred, untested) |
0x4068 | serena | Ultra Open Earbuds | ultra_open (partial) |
0x4082 | wolverine | QuietComfort Ultra Headphones (2nd Gen) | qc_ultra2 |
Known Unsupported (Future Targets)
See catalog.py / catalog.rs / catalog.h for the full device
list. Entries with config=None are recognized but not yet
implemented.
Adding a New Device
To add support for a new Bose device:
- Add to the catalog — add its PID, codename, and name with
config=None - Discover the RFCOMM channel —
connect()tries the config's channel, then probes 2, 8, 9 with a firmware GET (FALLBACK_CHANNELS); setRFCOMM_CHANNELto whichever answers on your unit - Check if an init packet is needed — send GET [0.1] and see if subsequent commands work
- Probe features — GET on known function addresses to see what responds
- Create a device config with the discovered addresses and parsers
- Register in the device registry — add to
get_device()/DEVICES - Set config in catalog — change
Noneto the new config key
Device-specific parsing (e.g. different ModeConfig layouts) is handled by assigning different parser and builder functions in the config. Some devices may still need a shared connection fallback when a newer direct-control feature is absent.
Language Implementations
All three implementations follow the architecture above. The differences are in how each language expresses the patterns.
Python (python/pybmap/)
pybmap/
├── __init__.py # connect() entry point, public API re-exports
├── catalog.py # Device catalog (PIDs, codenames, USB/Modalias IDs)
├── protocol.py # Packet codec (bmap_packet, parse_response)
├── transport.py # RfcommTransport (AF_BLUETOOTH socket)
├── connection.py # BmapConnection class
├── discovery.py # find_bmap_device() via bluetoothctl
├── constants.py # Operators, error codes, button/action/language tables
├── types.py # NamedTuples (BmapResponse, ModeConfig, ButtonMapping, etc.)
├── errors.py # Exception hierarchy
└── devices/
├── __init__.py # Device registry (DEVICES dict, get_device())
├── parsers.py # Shared parser/builder functions
├── qc_ultra2.py # QC Ultra 2 config (module-level constants)
├── qc_prince.py # QuietComfort Headphones / prince config
└── qc35.py # QC35 config (module-level constants)
Device configs are Python modules with module-level constants.
Features are dicts mapping names to {"addr": (fblock, func), "parser": fn, "builder": fn}.
This makes feature dispatch a simple dict lookup at runtime.
Error handling uses a typed exception hierarchy:
BmapError
├── BmapConnectionError — socket/transport failures
├── BmapAuthError — device returned error code 5
├── BmapDeviceError — device returned other error codes
├── BmapTimeoutError — no response within timeout
└── BmapNotFoundError — no device found during discovery
Transport uses Python's socket module with AF_BLUETOOTH on Linux
and IOBluetooth (PyObjC) on macOS. RfcommTransport is an alias for the
platform class chosen at import time.
Testing: pytest with real-capture test data. No transport mock in
parser tests (parsers are pure functions). Connection tests use a
MockTransport helper.
Rust (rust/src/)
rust/src/
├── lib.rs # connect() entry point, public re-exports
├── catalog.rs # Device catalog (PIDs, codenames, USB/Modalias IDs)
├── protocol.rs # Packet codec, BmapResponse struct
├── transport.rs # Transport trait + RfcommTransport (libc sockets)
├── connection.rs # BmapConnection<T: Transport> generic struct
├── discovery.rs # find_bmap_device() via bluetoothctl
├── device.rs # DeviceConfig struct, all parsers/builders
├── devices.rs # qc_ultra2() / qc35() factory functions
├── error.rs # BmapError enum, BmapResult type alias
└── main.rs # CLI binary (bmapctl)
Device configs are returned by factory functions (qc_ultra2() -> DeviceConfig).
Features are Option<Addr> fields on the struct — None means unsupported.
The compiler enforces that unsupported features can't be called without
handling the None case.
Feature dispatch is method-based rather than dict-based. Each
BmapConnection method knows which config field to read:
pub fn anr(&self) -> BmapResult<&'static str> {
let addr = self.addr(self.config.anr)?; // None → Unsupported error
let payload = self.get(addr)?;
Ok(parse_anr(&payload))
}
Transport is a trait (Transport), enabling mock transports in tests:
pub trait Transport {
fn send_recv(&self, packet: &[u8]) -> BmapResult<Vec<u8>>;
fn send_recv_drain(&self, packet: &[u8]) -> BmapResult<Vec<u8>>;
}
BmapConnection<T: Transport> is generic over the transport, so tests
use BmapConnection<MockTransport> with canned responses.
Error handling uses Result<T, BmapError> with an enum:
pub enum BmapError {
Connection(String),
Auth(String),
Device { message: String, code: u8 },
Timeout(String),
NotFound(String),
Unsupported(String),
InvalidArg(String),
}
C++ (cpp/src/)
cpp/src/
├── bmap.h # connect() function, public header
├── catalog.h # Device catalog (PIDs, codenames, USB/Modalias IDs)
├── protocol.h # Packet codec (header-only)
├── transport.h # Transport abstract class
├── transport.cpp # RfcommTransport (BlueZ sockets)
├── connection.h # BmapConnection class (header-only)
├── device.h # DeviceConfig, all parsers/builders (header-only)
├── devices.h # qc_ultra2() / qc35() inline functions
├── discovery.h # find_bmap_device() declaration
├── discovery.cpp # Discovery implementation
└── main.cpp # CLI binary (bmapctl)
Mostly header-only: Only transport.cpp and discovery.cpp have
compiled implementations (they use system calls). Everything else —
protocol, parsers, device configs, connection — is in headers.
Device configs are inline functions returning DeviceConfig structs.
Features use std::optional<Addr> — same pattern as Rust's Option<Addr>.
Transport is an abstract class with virtual methods:
class Transport {
public:
virtual std::vector<uint8_t> send_recv(const std::vector<uint8_t>& packet) = 0;
virtual std::vector<uint8_t> send_recv_drain(const std::vector<uint8_t>& packet) = 0;
virtual ~Transport() = default;
};
Tests use a MockTransport subclass.
Error handling uses std::runtime_error exceptions. No typed
hierarchy — auth errors (code 5) are not programmatically distinguishable
from other device errors without parsing the message string. The require() helper
converts std::nullopt to an exception for unsupported features:
static Addr require(const std::optional<Addr>& opt, const char* name) {
if (!opt) throw std::runtime_error(std::string(name) + " not supported");
return *opt;
}
Ownership: BmapConnection owns the transport via std::unique_ptr<Transport>.
The connect() function returns std::unique_ptr<BmapConnection>.
Building & Testing
A top-level Makefile orchestrates all three implementations.
Prerequisites: Python 3, Rust/Cargo, CMake, GCC/Clang, libbluetooth-dev.
Quick Start
make test # run all test suites (Python + Rust + C++)
make artifacts # build release binaries + SHA256SUMS
Available Targets
| Target | Description |
|---|---|
make test | Run all tests across all three languages |
make python-test | Python unit tests (auto-creates virtualenv) |
make rust-test | Rust tests via cargo test |
make cpp-test | C++ tests via CMake + ctest |
make artifacts | Build release binaries, strip, generate checksums |
make release VERSION=vX.Y.Z | Full release: test → build → gh release create |
make clean | Remove all build artifacts, venvs, dist/ |
make integration | Integration tests (requires paired Bluetooth device) |
make help | Show all targets |
Build Flow
graph LR
subgraph test
PT[python-test] --> T[test]
RT[rust-test] --> T
CT[cpp-test] --> T
end
subgraph artifacts
RB[rust-build<br/>cargo build --release] --> A[artifacts]
CB[cpp-build<br/>cmake Release] --> A
A --> STRIP[strip binaries]
STRIP --> SHA[SHA256SUMS]
end
T --> REL[release]
SHA --> REL
REL --> GH[gh release create]
style T fill:#26de81,stroke:#333,color:#fff
style A fill:#4a9eff,stroke:#333,color:#fff
style REL fill:#ff9f43,stroke:#333,color:#fff
style GH fill:#a55eea,stroke:#333,color:#fff
Release Process
# 1. Ensure all tests pass and artifacts build
make test
make artifacts
# 2. Create a tagged release with binaries
make release VERSION=v0.2.0
# This runs: test → artifacts → gh release create
# Binaries are named: bmapctl-{rust,cpp}-linux-{arch}
# SHA256SUMS is included for verification
Artifact Verification
# Download and verify
cd dist/
sha256sum -c SHA256SUMS
# Install
chmod +x bmapctl-rust-linux-x86_64
sudo cp bmapctl-rust-linux-x86_64 /usr/local/bin/bmapctl
Language-Specific Builds
Each language can be built independently:
# Python — virtualenv auto-created
make python-setup # create venv, install deps
make python-test # run pytest
make python-build # sdist + wheel in python/dist/
# Rust
make rust-build # cargo build --release
make rust-test # cargo test
# C++
make cpp-build # cmake + make (Debug)
make cpp-test # build + run bmap_tests