Skip to content
Merged
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
29 changes: 29 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,35 @@ the app uses to change modes in real time.
- Custom name: "Fargo"
- Product ID: 0x4082, Variant: 0x01

## QC Ultra 2 Earbuds — EDITH

- Product: Bose QuietComfort Ultra Earbuds (2nd Gen)
- Codename: `edith`
- Product ID: `0x4062`
- Platform: OTG-QCC-384
- Transport: Bluetooth SPP over RFCOMM channel 2
- Feature layout: shared with QC Ultra 2 headphones
- Hardware validation: physical `edith` device; battery components, status,
listening modes, and CNC behavior verified

### Component Battery Response

EDITH returns multiple four-byte battery records from `[2.2]`. Each record is
`[level, reserved, reserved, component_id]`:

| Component ID | Component | Example |
|--------------|-----------|---------|
| `0x01` | Right | `3cffff01` = 60% |
| `0x02` | Left | `3cffff02` = 60% |
| `0x03` | Case | `50ffff03` = 80% |
| `0x04` | Combined buds | `3cffff04` = 60%, used for generic Battery |

Records must be identified by component ID, not payload position. The generic
`Battery` value uses ID `0x04` (falling back to the lower of `0x01`/`0x02`
when `0x04` is absent or `0xFF`); the CLI separately displays IDs `0x01`, `0x02`,
and `0x03`. BMAP `[2.5]` tracks whether both earbuds are seated and did not
change across observed charger transitions, so case charging is not inferred.

## BMAP Protocol
- Version: 1.1.0
- Transport: Bluetooth SPP over RFCOMM channel 2
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ connection to the headphones.
| Device | NC Control | EQ | Spatial | Profiles | Buttons | Status |
| --------------------------- | ------------------------------------- | ------ | -------------- | --------------------- | ---------------------- | ------------------ |
| **QC Ultra Headphones 2** | CNC 0-10 slider | 3-band | room/head | 7 custom slots | Shortcut remap | Verified |
| **QC Ultra Earbuds 2** | CNC 0-10 (verified) | inherited | modes verified | inherited | inherited | Battery/status/modes/CNC verified (`edith`) |
| **QuietComfort Headphones** | CNC 0-10 + Wind Block via ModeConfig | 3-band | field observed | 2 user slots observed | — | Verified (`prince`) |
| **QuietComfort 35 / 35 II** | ANR off/high/wind/low | — | — | — | Action remap (VPA/ANC) | Verified |
| **QuietComfort Earbuds** | CNC 0-10 via direct SETGET | 3-band | — | 4 fixed modes | Remap | Verified (`lando`) |
Expand Down Expand Up @@ -97,6 +98,10 @@ bosectl buttons set ANC # Remap programmable button
bosectl quiet # Switch to Quiet mode
```

When `BMAP_MAC` (or Python CLI alias `BOSE_MAC`) is set, also set
`BMAP_DEVICE` to the matching config key. Device type is auto-detected only
when the MAC address is auto-detected.

### Device Catalog API

```python
Expand Down
7 changes: 6 additions & 1 deletion cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,10 @@ add_executable(bmap_tests
tests/test_catalog.cpp
tests/test_transport.cpp
)
target_link_libraries(bmap_tests PRIVATE bmap)
# Linked like bmapctl so tests can call the real connect(); no BT at runtime.
if(APPLE)
target_link_libraries(bmap_tests PRIVATE bmap)
else()
target_link_libraries(bmap_tests PRIVATE bmap bluetooth)
endif()
add_test(NAME bmap_tests COMMAND bmap_tests)
23 changes: 19 additions & 4 deletions cpp/src/bmap.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ inline constexpr uint8_t FALLBACK_CHANNELS[] = {2, 8, 9};

namespace detail {

inline void validate_device_override(
const std::string& mac_override,
const std::string& device_type_override)
{
if (!mac_override.empty() && device_type_override.empty()) {
throw std::invalid_argument(
"device_type is required when mac is specified");
}
}

inline void send_init(Transport& transport, const DeviceConfig& config) {
if (config.init_packet) {
auto pkt = bmap_packet(config.init_packet->fblock,
Expand Down Expand Up @@ -81,11 +91,19 @@ inline std::unique_ptr<RfcommTransport> open_transport(const std::string& mac,

} // namespace detail

/// Connect to a BMAP device, auto-detecting if mac/device_type are empty.
/// Follow-up hint for a failed connect(), or nullptr when the failure is a
/// caller setup mistake (std::invalid_argument) rather than a Bluetooth issue.
inline const char* connection_hint(const std::exception& error) {
if (dynamic_cast<const std::invalid_argument*>(&error)) return nullptr;
return "Is Bluetooth on? Are the headphones paired and connected?";
}

/// Connect to a BMAP device. Device type is resolved only during MAC discovery.
inline std::unique_ptr<BmapConnection> connect(
const std::string& mac_override = "",
const std::string& device_type_override = "")
{
detail::validate_device_override(mac_override, device_type_override);
std::string mac = mac_override;
std::string device_type = device_type_override;

Expand All @@ -100,9 +118,6 @@ inline std::unique_ptr<BmapConnection> connect(
device_type = detected->second;
}
}
if (device_type.empty()) {
device_type = "qc_ultra2";
}

auto config = get_device(device_type);
if (!config) {
Expand Down
2 changes: 1 addition & 1 deletion cpp/src/catalog.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ inline const std::vector<BoseDevice>& catalog() {
{0x404C, "celine_ii", "Frames (2nd Gen)", Category::Earbuds, nullptr},
{0x4060, "olivia", "Frames Tempo", Category::Earbuds, nullptr},
{0x4061, "vedder", "Frames", Category::Earbuds, nullptr},
{0x4062, "edith", "QuietComfort Ultra Earbuds (2nd Gen)", Category::Earbuds, "qc_ultra2"},
{0x4062, "edith", "QuietComfort Ultra Earbuds (2nd Gen)", Category::Earbuds, "qc_ultra2_earbuds"},
{0x4064, "smalls", "QuietComfort Earbuds II", Category::Earbuds, nullptr},
{0x4068, "serena", "Ultra Open Earbuds", Category::Earbuds, "ultra_open"},
{0x4072, "scotty", "QuietComfort Ultra Earbuds", Category::Earbuds, nullptr},
Expand Down
40 changes: 37 additions & 3 deletions cpp/src/connection.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <algorithm>
#include <array>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>

Expand All @@ -25,7 +26,38 @@ class BmapConnection {

// ── Read Operations ─────────────────────────────────────────────────────

uint8_t battery() { return parse_battery(get(require(config_.battery, "battery"))); }
uint8_t battery() {
return battery_status().aggregate;
}
BatteryStatus battery_status() {
auto payload = get(require(config_.battery, "battery"));
if (config_.battery_aggregate_id) {
auto readings = parse_battery_readings(payload);
for (const auto& reading : readings) {
if (reading.component_id == *config_.battery_aggregate_id)
return {reading.level, std::move(readings)};
}
// Aggregate missing or 0xFF: fall back to the lowest bud reading.
std::optional<uint8_t> lowest;
for (const auto& reading : readings) {
const auto& sources = config_.battery_aggregate_sources;
if (std::find(sources.begin(), sources.end(), reading.component_id) ==
sources.end())
continue;
if (!lowest || reading.level < *lowest) lowest = reading.level;
}
if (lowest) return {*lowest, std::move(readings)};
throw std::runtime_error(
"Battery response missing aggregate component " +
std::to_string(*config_.battery_aggregate_id));
}
if (payload.empty())
throw std::runtime_error("Empty battery response");
return {parse_battery(payload), {}};
}
std::vector<BatteryReading> battery_readings() {
return battery_status().readings;
}
std::string firmware() { return parse_firmware(get(require(config_.firmware, "firmware"))); }
std::string name() { return parse_product_name(get(require(config_.product_name, "product_name"))); }
std::pair<uint8_t, uint8_t> cnc() { return parse_cnc(get(require(config_.cnc, "cnc"))); }
Expand Down Expand Up @@ -90,9 +122,11 @@ class BmapConnection {
[&]{ return cnc(); }, {0, 10});
auto [prom_on, prom_lang] = safe_call<std::pair<bool,std::string>>(
[&]{ return prompts(); }, {false, ""});
auto battery_state = battery_status();

DeviceStatus s;
s.battery = battery();
s.battery = battery_state.aggregate;
s.battery_readings = std::move(battery_state.readings);
s.mode = mode_name;
s.mode_idx = idx;
s.cnc_level = cnc_cur;
Expand Down Expand Up @@ -293,7 +327,7 @@ class BmapConnection {
auto pkt = bmap_packet(addr.fblock, addr.func, Operator::Get);
auto data = transport_->send_recv(pkt);
auto resp = parse_response(data);
if (!resp) throw std::runtime_error("Empty response");
if (!resp) throw std::runtime_error("Invalid or empty response");
check_error(*resp);
return resp->payload;
}
Expand Down
39 changes: 39 additions & 0 deletions cpp/src/device.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
#include <cstdint>
#include <functional>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

#include "protocol.h"
Expand Down Expand Up @@ -59,13 +61,24 @@ struct ButtonMapping {
std::string action_name;
};

struct BatteryReading {
uint8_t component_id;
uint8_t level;
};

struct BatteryStatus {
uint8_t aggregate;
std::vector<BatteryReading> readings;
};

struct AudioSource {
std::string source_type;
std::string source_mac; // empty if not bluetooth
};

struct DeviceStatus {
uint8_t battery;
std::vector<BatteryReading> battery_readings;
std::string mode;
uint8_t mode_idx;
uint8_t cnc_level;
Expand All @@ -91,6 +104,12 @@ struct DeviceConfig {
/// Init packet required before device responds (QC35 needs GET [0.1]).
std::optional<Addr> init_packet;
std::optional<Addr> battery;
/// Component IDs and display labels for multi-cell battery responses.
std::vector<std::pair<uint8_t, std::string>> battery_components;
/// Component ID used for the generic aggregate battery value.
std::optional<uint8_t> battery_aggregate_id;
/// Component IDs whose lowest level stands in for a missing aggregate.
std::vector<uint8_t> battery_aggregate_sources;
std::optional<Addr> firmware;
std::optional<Addr> product_name;
std::optional<Addr> voice_prompts;
Expand Down Expand Up @@ -128,6 +147,26 @@ inline uint8_t parse_battery(const std::vector<uint8_t>& p) {
return p.empty() ? 0 : p[0];
}

/// Parse four-byte component battery records from [2.2].
inline std::vector<BatteryReading> parse_battery_readings(const std::vector<uint8_t>& p) {
if (p.size() % 4 != 0) {
throw std::invalid_argument("Malformed battery response");
}
std::vector<BatteryReading> readings;
std::vector<uint8_t> seen_components;
for (size_t i = 0; i + 3 < p.size(); i += 4) {
for (auto component_id : seen_components) {
if (component_id == p[i + 3]) {
throw std::invalid_argument(
"Duplicate battery component " + std::to_string(p[i + 3]));
}
}
seen_components.push_back(p[i + 3]);
if (p[i] <= 100) readings.push_back({p[i + 3], p[i]});
}
return readings;
}

inline std::string parse_firmware(const std::vector<uint8_t>& p) {
return {p.begin(), p.end()};
}
Expand Down
12 changes: 12 additions & 0 deletions cpp/src/devices.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ inline DeviceConfig qc_ultra2() {
return c;
}

/// Bose QuietComfort Ultra Earbuds 2 -- edith, product ID 0x4062.
/// Shares the QC Ultra 2 protocol layout and reports separate battery cells.
inline DeviceConfig qc_ultra2_earbuds() {
auto c = qc_ultra2();
c.info = {"Bose QuietComfort Ultra Earbuds (2nd Gen)", "edith", "OTG-QCC-384"};
c.battery_components = {{1, "Right"}, {2, "Left"}, {3, "Case"}};
c.battery_aggregate_id = 4;
c.battery_aggregate_sources = {1, 2};
return c;
}

/// Bose QuietComfort Headphones -- prince, product ID 0x4075.
/// Verified against firmware 1.0.6-80+f5f219b. RFCOMM channel 8.
inline DeviceConfig qc_prince() {
Expand Down Expand Up @@ -188,6 +199,7 @@ inline DeviceConfig ultra_open() {

inline std::optional<DeviceConfig> get_device(const std::string& name) {
if (name == "qc_ultra2") return qc_ultra2();
if (name == "qc_ultra2_earbuds") return qc_ultra2_earbuds();
if (name == "qc35") return qc35();
if (name == "qc_prince") return qc_prince();
if (name == "qc_earbuds") return qc_earbuds();
Expand Down
17 changes: 14 additions & 3 deletions cpp/src/main.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// bmapctl — Minimal CLI for controlling BMAP devices (C++ version).

#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <string>

Expand Down Expand Up @@ -30,7 +31,8 @@ static void usage() {
<< " off Power off\n\n"
<< "Environment:\n"
<< " BMAP_MAC=XX:XX:XX:XX:XX:XX Device MAC\n"
<< " BMAP_DEVICE=qc_ultra2|qc_prince|qc35 Device type\n";
<< " BMAP_DEVICE=qc_ultra2|qc_ultra2_earbuds|qc_prince|qc35|"
"qc_earbuds|qc45|ultra_open\n";
}

static bool is_on(const std::string& s) {
Expand All @@ -52,8 +54,8 @@ int main(int argc, char** argv) {
try {
devptr = connect(mac_str, dev_str);
} catch (const std::exception& e) {
std::cerr << "Connection failed: " << e.what() << "\n"
<< "Is Bluetooth on? Are the headphones paired and connected?\n";
std::cerr << "Connection failed: " << e.what() << "\n";
if (auto hint = connection_hint(e)) std::cerr << hint << "\n";
return 1;
}
auto& dev = *devptr;
Expand All @@ -63,6 +65,15 @@ int main(int argc, char** argv) {
auto s = dev.status();
std::cout << " Model " << dev.config().info.name << "\n";
std::cout << " Battery " << (int)s.battery << "%\n";
for (const auto& [id, label] : dev.config().battery_components) {
for (const auto& reading : s.battery_readings) {
if (id == reading.component_id) {
std::cout << " " << std::left << std::setw(12) << label
<< std::right << (int)reading.level << "%\n";
break;
}
}
}
if (!s.mode.empty())
std::cout << " Mode " << s.mode << "\n";
if (dev.has_feature("anr")) {
Expand Down
12 changes: 8 additions & 4 deletions cpp/src/protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,15 @@ inline std::vector<uint8_t> bmap_packet(uint8_t fblock, uint8_t func,

inline std::optional<BmapResponse> parse_response(const std::vector<uint8_t>& data) {
if (data.size() < 4) return std::nullopt;
auto raw_op = data[2] & 0x0F;
if (raw_op > static_cast<uint8_t>(Operator::Processing)) return std::nullopt;
BmapResponse resp;
resp.fblock = data[0];
resp.func = data[1];
resp.op = static_cast<Operator>(data[2] & 0x0F);
resp.op = static_cast<Operator>(raw_op);
uint8_t length = data[3];
size_t end = std::min<size_t>(4 + length, data.size());
resp.payload.assign(data.begin() + 4, data.begin() + end);
if (data.size() < 4 + length) return std::nullopt;
resp.payload.assign(data.begin() + 4, data.begin() + 4 + length);
return resp;
}

Expand All @@ -107,7 +109,9 @@ inline std::vector<BmapResponse> parse_all_responses(const std::vector<uint8_t>&
BmapResponse resp;
resp.fblock = data[pos];
resp.func = data[pos + 1];
resp.op = static_cast<Operator>(data[pos + 2] & 0x0F);
auto raw_op = data[pos + 2] & 0x0F;
if (raw_op > static_cast<uint8_t>(Operator::Processing)) break;
resp.op = static_cast<Operator>(raw_op);
uint8_t length = data[pos + 3];
if (pos + 4 + length > data.size()) break;
resp.payload.assign(data.begin() + pos + 4, data.begin() + pos + 4 + length);
Expand Down
2 changes: 1 addition & 1 deletion cpp/tests/test_catalog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ TEST(catalog_lookup_qc_ultra2_earbuds) {
ASSERT_TRUE(dev != nullptr);
ASSERT_EQ(std::string(dev->codename), std::string("edith"));
ASSERT_TRUE(dev->config != nullptr);
ASSERT_EQ(std::string(dev->config), std::string("qc_ultra2"));
ASSERT_EQ(std::string(dev->config), std::string("qc_ultra2_earbuds"));
}

TEST(catalog_lookup_qc_headphones_gen2) {
Expand Down
Loading