diff --git a/README.md b/README.md index 1d9609a..58d62de 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,11 @@ A cross-platform tool to control USB gaming headsets on **Linux**, **macOS**, an | Audeze Maxwell | All | x | x | | | x | x | x | | x | | | | | x | | | | | Audeze Maxwell 2 | All | x | x | | | x | x | x | | x | | | | | | | | x | | Lenovo Wireless VoIP Headset | All | x | x | | | x | | x | x | x | | | x | | x | | | | +| Plantronics Voyager 8200 UC (BT600) | L/W | x | x | | x | | | x | | | | | | | x | | | | | Sony INZONE Buds | All | | x | | | | | | | | | | | | | | | | | HeadsetControl Test device | All | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | -**Platform:** All = Linux, macOS, Windows | L/M = Linux and macOS only +**Platform:** All = Linux, macOS, Windows | L/M = Linux and macOS only | L/W = Linux and Windows only > **Note:** Some Corsair headsets may need additional configuration - see [Adding a Corsair device](docs/ADDING_A_CORSAIR_DEVICE.md). Some headsets (HS80, HS70 wired, RGB Elite, Virtuoso) expose sidetone via ALSA mixer instead. diff --git a/lib/device_registry.cpp b/lib/device_registry.cpp index c2c29c2..403660e 100644 --- a/lib/device_registry.cpp +++ b/lib/device_registry.cpp @@ -50,6 +50,9 @@ // Lenovo devices #include "devices/lenovo_wireless_voip.hpp" +// Plantronics / Poly devices +#include "devices/plantronics_bt600.hpp" + // Sony devices #include "devices/sony_inzone_buds.hpp" @@ -144,6 +147,9 @@ void DeviceRegistry::initialize() // Lenovo devices registerDevice(std::make_unique()); + // Plantronics / Poly devices + registerDevice(std::make_unique()); + // Sony devices registerDevice(std::make_unique()); diff --git a/lib/devices/plantronics_bt600.hpp b/lib/devices/plantronics_bt600.hpp new file mode 100644 index 0000000..1664d75 --- /dev/null +++ b/lib/devices/plantronics_bt600.hpp @@ -0,0 +1,429 @@ +#pragma once + +#include "../result_types.hpp" +#include "device_utils.hpp" +#include "hid_device.hpp" +#include +#include +#include + +using namespace std::string_view_literals; + +namespace headsetcontrol { + +/** + * @brief Plantronics/Poly Voyager 8200 UC (via BT600 USB dongle) + * + * Features: + * - Sidetone (3 discrete levels: Low / Medium / High) + * - Battery status (charging state + remaining talk time, reported as an + * estimated percentage plus time-to-empty minutes) + * - Lights (the "online indicator" LED) + * - Voice prompts (the "notification tones" setting) + * - Volume limiter (the "noise exposure" limit, mapped to 85 dB on/off) + * + * Protocol + * -------- + * The dongle speaks Poly's native get/set protocol tunneled over HID report + * ID 0x07 in the vendor collection usage-page 0xFFA2 / usage 0x03 on + * interface 3. Commands are sent as OUTPUT reports (SET_REPORT via EP0; the + * interface has no interrupt-OUT endpoint) and answered as INPUT reports on + * interrupt EP 0x81. Frame layout (64-byte report, zero padded): + * + * 07 01 01 10 00 00 + * + * The addr byte selects the target: 0x00 = the dongle itself, 0x20 = the + * headset via Bluetooth. Replies from the headset carry addr 0x02, replies + * from the dongle carry addr 0x00 (same as its unsolicited events). + * + * HELLO (open session): op=0x01 -> 07 01 01 10 07 00 00 01 01 02 + * HELLO ack: op=0x08 -> 07 01 01 10 06 00 00 08 01 02 + * GET (read a setting): op=0x02 -> 07 01 01 10 06 20 00 00 02 GG II + * SET (write a setting): op=0x05 -> 07 01 01 10 07 20 00 00 05 GG II VV + * GET reply: op=0x03 -> 07 01 01 10 LL 02 00 00 03 GG II + * SET ack ("changed"): op=0x0a -> 07 01 01 10 LL 02 00 00 0a GG II VV + * + * The dongle ignores every GET/SET until sessions have been opened, which + * requires (in this order, as the Windows stack does at device attach): a + * 2-byte output report 06 01, a HELLO addressed to the dongle (arms the + * relay to the headset; without it a freshly powered-on headset never + * answers), and a HELLO addressed to the headset. Sessions persist until + * the dongle loses power or the headset reboots, and the sequence is + * idempotent, so it is sent before every operation. Each HELLO ack is + * followed by a multi-fragment catalogue of supported settings (frames + * whose header bytes differ from 01 01 10); those are skipped by the reply + * matcher, as are unsolicited events. + * + * With the headset powered off or out of range the headset-addressed HELLO + * and all GETs/SETs stay unanswered, so a read timeout is reported as + * device-offline. + * + * Battery (group 0x0A, item 0x1A) value: + * , e.g. 07 0B 00 02 EE 01. + * level = current charge level (0 .. level_count-1) + * level_count = number of discrete levels including 0 (0x0B = 11 here, so + * the denominator is 10) + * charging = 0/1 + * minutes = remaining talk time (time to empty), reported separately + * Percentage = level / (level_count - 1) * 100. This is Poly's own formula: + * its software computes level/numLevels*100 (numLevels = level_count-1), which + * was verified live (level 7 -> 70%, level 6 -> 60%, matching Poly Studio). + * The talk-time minutes are reported as time-to-empty; they drift within a + * level band, which is why they are not used for the percentage. + * + * Known settings without a matching HeadsetControl capability (names are + * Poly's own, from the Deckard device model in Poly Studio; documented here + * so the knowledge is not lost): + * + * grp/item Poly name values + * 04/03 volumeLevelTone atEveryLevel / minMaxOnly + * 04/06 secondInboundCall ignore / once / continuous + * 04/08 scoTone off / on (active audio tone) + * 04/0A muteAlert off / timed / voiceAudible / voiceVisible + * 04/0B muteTone voice / singleTone / doubleTone + * 04/0C answeringCallVP off / on (answering-call voice prompt) + * 04/0D incomingCallVibration off / on + * 04/0E chargerPluginVibration off / on + * 04/0F ancTimerDuration off / 2 / 4 + * 08/04 callerID off / on (00 / FF) + * 0A/0E A2DP off / on (streaming audio) + * 0A/22 muteReminderFrequency 1..15 + * 0A/30 spokenAnswerIgnore off / on + * 0F/0C G616 off / on (anti-startle / sound limiting) + * 0F/0E twa off / 85db / 80db (implemented, see below) + * 0F/10 twaPeriod 2 / 4 / 6 / 8 (hours) + * 0F/15 ringToneMobile sound1 / sound2 / sound3 / off + * 0F/16 ringToneVoip sound1 / sound2 / sound3 / off + * 0F/18 audioBandwidthMobile narrowband / wideband (HD voice) + * 0F/3A extendedRangeMode off / on + * + * (Plus reporting toggles: acousticIncidentReporting 0F/01, aalTwaReporting + * 0F/07, conversationDynamicsReporting 0F/0D, linkQualityReporting 0F/54.) + * + * Read-only items observed on the headset address: 0A/00 product name + * (UTF-16), 0A/01 serial number string, 0A/03 build code string, 0A/1E + * Bluetooth address. Open mic (listen-through) is not a settable item; its + * state arrives as a "changed" event on 0E/1E (0x0C=on, 0x00=off), and each + * headset button press fires an event on 0E/2B. The 0x9A report is a + * separate feature-report request/response channel for device info + * (SET_FEATURE 9a 60 00 01 then GET_FEATURE: n=01 dongle firmware, + * n=02 headset firmware, n=04 friendly name, n=06 headset Bluetooth ID, + * n=07 dongle serial) and for the event queue (9b 01 input pulse signals a + * pending event; SET_FEATURE 9a 40 00 then GET_FEATURE pops it). + * + * Reverse-engineered from USBPcap/usbmon captures of Plantronics Hub and + * Poly Studio and validated live against the hardware (firmware v2120). + */ +class PlantronicsBT600 : public HIDDevice { +public: + static constexpr size_t MSG_SIZE = 64; + static constexpr uint8_t REPORT_ID = 0x07; + + static constexpr std::array PRODUCT_IDS { + 0x02EE // Plantronics BT600 dongle (Voyager 8200 UC) + }; + + // Field offsets inside a reply frame + static constexpr size_t OFF_ADDR = 5; // source/target address + static constexpr size_t OFF_OP = 8; + static constexpr size_t OFF_GRP = 9; + static constexpr size_t OFF_ITEM = 10; + static constexpr size_t OFF_VALUE = 11; // first value byte + + // Battery reply value layout: + // + static constexpr size_t OFF_BATT_LEVEL = 11; + static constexpr size_t OFF_BATT_COUNT = 12; // number of levels incl. 0 + static constexpr size_t OFF_BATT_CHARGING = 13; + static constexpr size_t OFF_BATT_MIN_HI = 14; + static constexpr size_t OFF_BATT_MIN_LO = 15; + +private: + // Fixed frame header/markers + static constexpr uint8_t HDR1 = 0x01, HDR2 = 0x01, HDR3 = 0x10; + static constexpr uint8_t ADDR_HEADSET = 0x20; // request to the headset + static constexpr uint8_t ADDR_HEADSET_REPLY = 0x02; // reply from the headset + static constexpr uint8_t ADDR_DONGLE = 0x00; // request to / reply from the dongle + static constexpr uint8_t OP_HELLO = 0x01; + static constexpr uint8_t OP_GET = 0x02; + static constexpr uint8_t OP_SET = 0x05; + static constexpr uint8_t OP_HELLO_ACK = 0x08; + static constexpr uint8_t OP_GET_REPLY = 0x03; + + // Output report that precedes session setup in the Windows attach + // sequence; required before a freshly powered-on headset will answer. + static constexpr std::array SESSION_NUDGE { 0x06, 0x01 }; + + // HELLO command payload (sits in the grp/item slots of the frame) + static constexpr uint8_t HELLO_ARG1 = 0x01; + static constexpr uint8_t HELLO_ARG2 = 0x02; + + // Setting addresses (group / item) + static constexpr uint8_t GRP_SETTINGS = 0x04; + static constexpr uint8_t ITEM_ONLINE_LED = 0x09; // Poly "enableOLI" + static constexpr uint8_t ITEM_SIDETONE = 0x10; // Poly "sideToneLevel" + static constexpr uint8_t ITEM_NOTIF_TONES = 0x11; // Poly "enableNotificationTones" + static constexpr uint8_t GRP_BATTERY = 0x0A; + static constexpr uint8_t ITEM_BATTERY = 0x1A; + static constexpr uint8_t GRP_CALL_AUDIO = 0x0F; + static constexpr uint8_t ITEM_TWA_LIMIT = 0x0E; // Poly "twa" (noise dose limit) + + // twa (time-weighted-average noise limit): 00=off, 01=85 dB, 02=80 dB + static constexpr uint8_t TWA_OFF = 0x00; + static constexpr uint8_t TWA_85DB = 0x01; + + // Sidetone device levels + static constexpr uint8_t SIDETONE_LEVELS = 3; // 0=Low, 1=Medium, 2=High + + // Skip this many non-matching reports (events, catalogue fragments) + // before giving up on a reply + static constexpr int MAX_READ_ATTEMPTS = 16; + + /** + * @brief Build a request frame. + */ + static constexpr std::array makeFrame(uint8_t op, uint8_t grp, uint8_t item, uint8_t value, uint8_t addr = ADDR_HEADSET) + { + std::array f {}; + f[0] = REPORT_ID; + f[1] = HDR1; + f[2] = HDR2; + f[3] = HDR3; + f[4] = (op == OP_GET) ? uint8_t(0x06) : uint8_t(0x07); // payload length + f[5] = addr; + f[8] = op; + f[9] = grp; + f[10] = item; + if (op == OP_SET) + f[11] = value; + return f; + } + + /** + * @brief Read until a solicited reply for (grp, item) arrives. + * + * Skips unsolicited events (dir=0x00), catalogue fragments (header + * bytes differ) and replies for other settings. A read timeout means + * the headset is not linked to the dongle. + * + * @param op Expected reply op, or 0 to accept any solicited reply + */ + Result awaitReply(hid_device* device_handle, uint8_t op, uint8_t grp, uint8_t item, std::array& response, uint8_t addr = ADDR_HEADSET_REPLY) const + { + for (int attempt = 0; attempt < MAX_READ_ATTEMPTS; ++attempt) { + auto read_result = readHIDTimeout(device_handle, response, hsc_device_timeout); + if (!read_result) { + if (read_result.error().code == DeviceError::Code::Timeout) { + return DeviceError::deviceOffline("Headset not connected to BT600 adapter (powered off or out of range)"); + } + return read_result.error(); + } + if (isReplyFor(response, grp, item, addr) && (op == 0 || response[OFF_OP] == op)) { + return {}; + } + } + return DeviceError::protocolError("No reply for the requested setting"); + } + + /** + * @brief Open the dongle- and headset-side settings sessions. + * + * Replays the init the Windows stack performs at device attach: the + * 06 01 output report, a HELLO to the dongle (arms the relay to the + * headset), and a HELLO to the headset. Required after the dongle + * powers up or the headset reboots; idempotent, so it is sent before + * every operation. + */ + Result openSession(hid_device* device_handle) const + { + if (auto result = writeHID(device_handle, SESSION_NUDGE); !result) { + return result.error(); + } + + auto dongle_hello = makeFrame(OP_HELLO, HELLO_ARG1, HELLO_ARG2, 0, ADDR_DONGLE); + if (auto result = writeHID(device_handle, dongle_hello); !result) { + return result.error(); + } + + std::array response {}; + if (auto result = awaitReply(device_handle, OP_HELLO_ACK, HELLO_ARG1, HELLO_ARG2, response, ADDR_DONGLE); !result) { + return result.error(); + } + + auto headset_hello = makeFrame(OP_HELLO, HELLO_ARG1, HELLO_ARG2, 0, ADDR_HEADSET); + if (auto result = writeHID(device_handle, headset_hello); !result) { + return result.error(); + } + + return awaitReply(device_handle, OP_HELLO_ACK, HELLO_ARG1, HELLO_ARG2, response, ADDR_HEADSET_REPLY); + } + + /** + * @brief Open the sessions, write a setting and wait for the device ack. + */ + Result applySetting(hid_device* device_handle, uint8_t grp, uint8_t item, uint8_t value) + { + if (auto result = openSession(device_handle); !result) { + return result.error(); + } + + auto request = makeFrame(OP_SET, grp, item, value); + if (auto result = writeHID(device_handle, request); !result) { + return result.error(); + } + + // The device acks a SET with a "changed" report for the same + // group/item; without it the write went nowhere (headset off). + std::array response {}; + return awaitReply(device_handle, 0, grp, item, response); + } + +public: + constexpr uint16_t getVendorId() const override + { + return 0x047F; // Plantronics, Inc. + } + + constexpr std::vector getProductIds() const override + { + return { PRODUCT_IDS.begin(), PRODUCT_IDS.end() }; + } + + constexpr std::string_view getDeviceName() const override + { + return "Plantronics Voyager 8200 UC (BT600)"sv; + } + + constexpr int getCapabilities() const override + { + return B(CAP_SIDETONE) | B(CAP_BATTERY_STATUS) | B(CAP_LIGHTS) | B(CAP_VOICE_PROMPTS) | B(CAP_VOLUME_LIMITER); + } + + constexpr uint8_t getSupportedPlatforms() const override + { + // Untested on macOS: the dongle exposes five top-level HID + // collections and macOS opens only the first enumerated one, which + // may not be the Poly control collection (see get_hid_path). + return PLATFORM_LINUX | PLATFORM_WINDOWS; + } + + constexpr capability_detail getCapabilityDetail([[maybe_unused]] enum capabilities cap) const override + { + // Poly vendor control collection on interface 3. + return { .usagepage = 0xFFA2, .usageid = 0x03, .interface_id = 0x03 }; + } + + /** + * @brief Whether a report is a solicited reply for the given setting. + * + * The header check also rejects the catalogue fragments the dongle + * streams after a HELLO (their bytes 1-3 differ from 01 01 10). + * + * @param addr Expected source address (0x02 = headset, 0x00 = dongle) + */ + static constexpr bool isReplyFor(const std::array& response, uint8_t grp, uint8_t item, uint8_t addr = 0x02) + { + return response[0] == REPORT_ID && response[1] == HDR1 + && response[2] == HDR2 && response[3] == HDR3 + && response[OFF_ADDR] == addr + && response[OFF_GRP] == grp && response[OFF_ITEM] == item; + } + + /** + * @brief Parse the value of a battery GET reply (group 0x0A, item 0x1A). + * + * Percentage is level / (level_count - 1) * 100, exactly as Poly's own + * software computes it. The remaining talk time is reported separately as + * time-to-empty. + */ + static Result parseBatteryReply(const std::array& response) + { + const uint8_t level = response[OFF_BATT_LEVEL]; + const uint8_t count = response[OFF_BATT_COUNT]; + if (count < 2) { + // Need at least two levels for a denominator; anything less is not + // a battery structure (e.g. an empty/short reply). + return DeviceError::protocolError("Unexpected battery reply format"); + } + + const bool charging = response[OFF_BATT_CHARGING] != 0; + const int minutes = bytes_to_uint16_be(response[OFF_BATT_MIN_HI], response[OFF_BATT_MIN_LO]); + const int percent = std::min(100, level * 100 / (count - 1)); + + BatteryResult battery {}; + battery.level_percent = percent; + battery.status = charging ? BATTERY_CHARGING : BATTERY_AVAILABLE; + battery.time_to_empty_min = minutes; + battery.raw_data = std::vector(response.begin(), response.end()); + return battery; + } + + Result setSidetone(hid_device* device_handle, uint8_t level) override + { + // Map normalized 0-128 onto the 3 device levels (0=Low, 1=Medium, 2=High). + const uint8_t device_level = mapSidetoneToDiscrete(level); + + if (auto result = applySetting(device_handle, GRP_SETTINGS, ITEM_SIDETONE, device_level); !result) { + return result.error(); + } + + return SidetoneResult { + .current_level = device_level, + .min_level = 0, + .max_level = 128, + .device_min = 0, + .device_max = SIDETONE_LEVELS - 1 + }; + } + + Result setLights(hid_device* device_handle, bool on) override + { + if (auto result = applySetting(device_handle, GRP_SETTINGS, ITEM_ONLINE_LED, on ? 0x01 : 0x00); !result) { + return result.error(); + } + + return LightsResult { .enabled = on }; + } + + Result setVoicePrompts(hid_device* device_handle, bool enabled) override + { + if (auto result = applySetting(device_handle, GRP_SETTINGS, ITEM_NOTIF_TONES, enabled ? 0x01 : 0x00); !result) { + return result.error(); + } + + return VoicePromptsResult { .enabled = enabled }; + } + + Result setVolumeLimiter(hid_device* device_handle, bool enabled) override + { + // Poly "twa" noise-dose limit; switching between "85 dB" and off. The + // device also has an 80 dB option (0x02) not exposed by this boolean + // capability. + if (auto result = applySetting(device_handle, GRP_CALL_AUDIO, ITEM_TWA_LIMIT, enabled ? TWA_85DB : TWA_OFF); !result) { + return result.error(); + } + + return VolumeLimiterResult { .enabled = enabled }; + } + + Result getBattery(hid_device* device_handle) override + { + if (auto result = openSession(device_handle); !result) { + return result.error(); + } + + auto request = makeFrame(OP_GET, GRP_BATTERY, ITEM_BATTERY, 0); + if (auto result = writeHID(device_handle, request); !result) { + return result.error(); + } + + std::array response {}; + if (auto result = awaitReply(device_handle, OP_GET_REPLY, GRP_BATTERY, ITEM_BATTERY, response); !result) { + return result.error(); + } + + return parseBatteryReply(response); + } +}; + +} // namespace headsetcontrol diff --git a/tests/test_device_registry.cpp b/tests/test_device_registry.cpp index 058d77b..9d13cad 100644 --- a/tests/test_device_registry.cpp +++ b/tests/test_device_registry.cpp @@ -217,6 +217,25 @@ void testLookupLogitechProX2Lightspeed() std::cout << " OK lookup Logitech PRO X2 LIGHTSPEED" << std::endl; } +void testLookupPlantronicsBT600() +{ + std::cout << " Testing lookup of Plantronics BT600 (0x02ee)..." << std::endl; + + auto& registry = DeviceRegistry::instance(); + registry.initialize(); + + auto* device = registry.getDevice(0x047f, 0x02ee); + ASSERT_NOT_NULL(device, "Plantronics BT600 should be found"); + ASSERT_EQ("Plantronics Voyager 8200 UC (BT600)", std::string(device->getDeviceName()), "Device name should match"); + ASSERT_TRUE((device->getCapabilities() & B(CAP_SIDETONE)) != 0, "BT600 should expose sidetone capability"); + ASSERT_TRUE((device->getCapabilities() & B(CAP_BATTERY_STATUS)) != 0, "BT600 should expose battery capability"); + ASSERT_TRUE((device->getCapabilities() & B(CAP_LIGHTS)) != 0, "BT600 should expose lights capability"); + ASSERT_TRUE((device->getCapabilities() & B(CAP_VOICE_PROMPTS)) != 0, "BT600 should expose voice prompts capability"); + ASSERT_TRUE((device->getCapabilities() & B(CAP_VOLUME_LIMITER)) != 0, "BT600 should expose volume limiter capability"); + + std::cout << " OK lookup Plantronics BT600" << std::endl; +} + // ============================================================================ // Device Enumeration Tests // ============================================================================ @@ -468,6 +487,7 @@ void runAllDeviceRegistryTests() std::cout << "\n=== Device Lookup Tests ===" << std::endl; runTest("Lookup Test Device", testLookupTestDevice); runTest("Lookup Logitech PRO X2 LIGHTSPEED", testLookupLogitechProX2Lightspeed); + runTest("Lookup Plantronics BT600", testLookupPlantronicsBT600); runTest("Lookup Non-Existent", testLookupNonExistentDevice); runTest("Lookup Wrong Product ID", testLookupWithWrongProductId); runTest("Lookup Wrong Vendor ID", testLookupWithWrongVendorId); diff --git a/tests/test_protocols.cpp b/tests/test_protocols.cpp index 7ec6e85..cc5fbf4 100644 --- a/tests/test_protocols.cpp +++ b/tests/test_protocols.cpp @@ -12,9 +12,10 @@ #include "device.hpp" #include "devices/corsair_device.hpp" #include "devices/logitech_gpro_x2_lightspeed.hpp" -#include "devices/protocols/logitech_centurion_protocol.hpp" +#include "devices/plantronics_bt600.hpp" #include "devices/protocols/hidpp_protocol.hpp" #include "devices/protocols/logitech_calibrations.hpp" +#include "devices/protocols/logitech_centurion_protocol.hpp" #include "devices/protocols/steelseries_protocol.hpp" #include "result_types.hpp" #include "utility.hpp" @@ -266,7 +267,7 @@ void testLogitechProX2BatteryPacketParsing() ASSERT_EQ(87, result->level_percent, "Direct percentage should be parsed from byte 10"); ASSERT_EQ(BATTERY_CHARGING, result->status, "Charging status should map from byte 12"); - response[12] = 0x00; + response[12] = 0x00; auto discharging_result = LogitechGProX2Lightspeed::parseBatteryResponse(response); ASSERT_TRUE(discharging_result.hasValue(), "Discharging packet should parse successfully"); ASSERT_EQ(BATTERY_AVAILABLE, discharging_result->status, "Non-0x02 status should be available"); @@ -322,13 +323,13 @@ void testLogitechProX2BatteryOutOfRange() ASSERT_TRUE(!result.hasValue(), "Battery level 101 should be rejected as out of range"); // Boundary: 100 should be valid - response[10] = 100; + response[10] = 100; auto valid_result = LogitechGProX2Lightspeed::parseBatteryResponse(response); ASSERT_TRUE(valid_result.hasValue(), "Battery level 100 should be valid"); ASSERT_EQ(100, valid_result->level_percent, "Battery level should be 100"); // Boundary: 0 should be valid - response[10] = 0; + response[10] = 0; auto zero_result = LogitechGProX2Lightspeed::parseBatteryResponse(response); ASSERT_TRUE(zero_result.hasValue(), "Battery level 0 should be valid"); ASSERT_EQ(0, zero_result->level_percent, "Battery level should be 0"); @@ -454,12 +455,12 @@ void testLogitechProX2OnboardEqPayloadBuilding() std::cout << " Testing Logitech PRO X2 onboard EQ payload building..." << std::endl; auto payload = LogitechGProX2Lightspeed::buildOnboardEqPayloadForTest(0x00, { - { 80, 4, 1 }, - { 240, 2, 1 }, - { 750, 0, 1 }, - { 2200, 0, 1 }, - { 6600, 0, 1 }, - }); + { 80, 4, 1 }, + { 240, 2, 1 }, + { 750, 0, 1 }, + { 2200, 0, 1 }, + { 6600, 0, 1 }, + }); ASSERT_TRUE(payload.size() > 32, "Onboard EQ payload should include coefficient sections"); ASSERT_EQ(0x00, payload[0], "Onboard EQ payload should start with slot 0"); @@ -472,6 +473,101 @@ void testLogitechProX2OnboardEqPayloadBuilding() std::cout << " [OK] Logitech PRO X2 onboard EQ payload building verified" << std::endl; } +// ============================================================================ +// Plantronics Protocol Tests +// ============================================================================ + +void testPlantronicsBT600BatteryParsing() +{ + std::cout << " Testing Plantronics BT600 battery reply parsing..." << std::endl; + + // Battery GET reply captured from the hardware: level 7 of 11 (=70%), + // not charging, 750 min talk time remaining: + // 07 01 01 10 0c 02 00 00 03 0a 1a 07 0b 00 02 ee 01 + std::array response { + 0x07, 0x01, 0x01, 0x10, 0x0c, 0x02, 0x00, 0x00, + 0x03, 0x0a, 0x1a, 0x07, 0x0b, 0x00, 0x02, 0xee, 0x01 + }; + + auto result = PlantronicsBT600::parseBatteryReply(response); + ASSERT_TRUE(result.hasValue(), "Battery reply should parse successfully"); + ASSERT_EQ(70, result->level_percent, "Level 7 of 11 should map to 70 percent"); + ASSERT_EQ(750, result->time_to_empty_min.value(), "Talk time minutes should be reported"); + ASSERT_EQ(BATTERY_AVAILABLE, result->status, "Zero charging byte should map to available"); + + // Captured one level lower: level 6 of 11 (=60%), 714 min. This is the + // case the earlier talk-time estimate got wrong. + response[PlantronicsBT600::OFF_BATT_LEVEL] = 0x06; + response[PlantronicsBT600::OFF_BATT_MIN_HI] = 0x02; + response[PlantronicsBT600::OFF_BATT_MIN_LO] = 0xca; + auto lower_result = PlantronicsBT600::parseBatteryReply(response); + ASSERT_TRUE(lower_result.hasValue(), "Lower-level reply should parse successfully"); + ASSERT_EQ(60, lower_result->level_percent, "Level 6 of 11 should map to 60 percent"); + ASSERT_EQ(714, lower_result->time_to_empty_min.value(), "Talk time minutes should be reported"); + + // Charging: charging byte set + response[PlantronicsBT600::OFF_BATT_CHARGING] = 0x01; + auto charging_result = PlantronicsBT600::parseBatteryReply(response); + ASSERT_TRUE(charging_result.hasValue(), "Charging reply should parse successfully"); + ASSERT_EQ(BATTERY_CHARGING, charging_result->status, "Nonzero charging byte should map to charging"); + + // A full battery (level == level_count - 1) is exactly 100 percent + response[PlantronicsBT600::OFF_BATT_LEVEL] = 0x0a; + response[PlantronicsBT600::OFF_BATT_CHARGING] = 0x00; + auto full_result = PlantronicsBT600::parseBatteryReply(response); + ASSERT_TRUE(full_result.hasValue(), "Full-battery reply should parse successfully"); + ASSERT_EQ(100, full_result->level_percent, "Level 10 of 11 should map to 100 percent"); + + // A reply with too few levels (no valid denominator) should be rejected + response[PlantronicsBT600::OFF_BATT_COUNT] = 0x01; + auto bad_result = PlantronicsBT600::parseBatteryReply(response); + ASSERT_TRUE(!bad_result.hasValue(), "Reply without a usable level count should be rejected"); + + std::cout << " [OK] Plantronics BT600 battery reply parsing verified" << std::endl; +} + +void testPlantronicsBT600ReplyMatching() +{ + std::cout << " Testing Plantronics BT600 reply matching..." << std::endl; + + // Solicited sidetone SET ack captured from the hardware: + // 07 01 01 10 07 02 00 00 0a 04 10 01 + std::array ack { + 0x07, 0x01, 0x01, 0x10, 0x07, 0x02, 0x00, 0x00, + 0x0a, 0x04, 0x10, 0x01 + }; + ASSERT_TRUE(PlantronicsBT600::isReplyFor(ack, 0x04, 0x10), "Sidetone ack should match its group/item"); + ASSERT_TRUE(!PlantronicsBT600::isReplyFor(ack, 0x0a, 0x1a), "Sidetone ack should not match the battery setting"); + + // Unsolicited event (address byte 0x00) must not be treated as a reply: + // 07 01 01 10 0a 00 00 00 0a 0e 1e 07 02 0c + std::array event { + 0x07, 0x01, 0x01, 0x10, 0x0a, 0x00, 0x00, 0x00, + 0x0a, 0x0e, 0x1e, 0x07, 0x02, 0x0c + }; + ASSERT_TRUE(!PlantronicsBT600::isReplyFor(event, 0x0e, 0x1e), "Unsolicited event should not match as a reply"); + + // Catalogue fragment streamed after HELLO (header bytes differ, but the + // byte at the reply-marker offset happens to be 0x02) must not match: + // 07 01 07 11 9c 02 00 00 09 00 36 00 01 02 ... + std::array fragment { + 0x07, 0x01, 0x07, 0x11, 0x9c, 0x02, 0x00, 0x00, + 0x09, 0x00, 0x36, 0x00, 0x01, 0x02 + }; + ASSERT_TRUE(!PlantronicsBT600::isReplyFor(fragment, 0x00, 0x36), "Catalogue fragment should not match as a reply"); + + // Dongle-addressed HELLO ack (source address 0x00) captured from the + // hardware: 07 01 01 10 06 00 00 00 08 01 02 + std::array dongle_ack { + 0x07, 0x01, 0x01, 0x10, 0x06, 0x00, 0x00, 0x00, + 0x08, 0x01, 0x02 + }; + ASSERT_TRUE(PlantronicsBT600::isReplyFor(dongle_ack, 0x01, 0x02, 0x00), "Dongle hello ack should match with dongle address"); + ASSERT_TRUE(!PlantronicsBT600::isReplyFor(dongle_ack, 0x01, 0x02), "Dongle hello ack should not match with the headset address"); + + std::cout << " [OK] Plantronics BT600 reply matching verified" << std::endl; +} + // ============================================================================ // SteelSeries Protocol Tests // ============================================================================ @@ -728,6 +824,10 @@ void runAllProtocolTests() runTest("Logitech PRO X2 EQ Quantization", testLogitechProX2OnboardEqCoefficientQuantization); runTest("Logitech PRO X2 Onboard EQ Payload", testLogitechProX2OnboardEqPayloadBuilding); + std::cout << "\n=== Plantronics Protocol ===" << std::endl; + runTest("Plantronics BT600 Battery Parsing", testPlantronicsBT600BatteryParsing); + runTest("Plantronics BT600 Reply Matching", testPlantronicsBT600ReplyMatching); + std::cout << "\n=== SteelSeries Protocol ===" << std::endl; runTest("SteelSeries Packet Sizes", testSteelSeriesPacketSizes); runTest("SteelSeries Battery Mapping", testSteelSeriesBatteryMapping);