From 6f6d4b55608396ef13ed405233f43d726883d302 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 2 Jul 2026 23:57:58 -0400 Subject: [PATCH] Add Plantronics/Poly Voyager 8200 UC (BT600) support Adds a device implementation for the Plantronics/Poly Voyager 8200 UC headset via the BT600 USB dongle (047F:02EE), supporting sidetone, battery status, lights (the online indicator LED), voice prompts (the notification tones setting) and volume limiter (the noise exposure limit). 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 and answered as INPUT reports on interrupt EP 0x81. The address byte selects the target (0x00 = dongle, 0x20 = headset; replies carry 0x00 / 0x02): HELLO (open session): 07 01 01 10 07 00 00 01 01 02 HELLO ack: 07 01 01 10 06 00 00 08 01 02 GET setting: 07 01 01 10 06 20 00 00 02 SET setting: 07 01 01 10 07 20 00 00 05 GET reply: 07 01 01 10 02 00 00 03 SET ack: 07 01 01 10 02 00 00 0a The dongle ignores all GETs/SETs until sessions are opened with a 06 01 output report, a HELLO to the dongle (arms the relay to the headset) and a HELLO to the headset - the same init the Windows stack performs at device attach. Sessions persist until the dongle loses power or the headset reboots; the sequence is idempotent and sent before every operation. - Sidetone: group 0x04 / item 0x10, 3 levels (0=Low, 1=Med, 2=High) - Lights: group 0x04 / item 0x09 ("online indicator", 0/1) - Voice prompts: group 0x04 / item 0x11 ("notification tones", 0/1) - Volume limiter: group 0x0F / item 0x0E ("noise exposure": 0=none, 1=85 dB, 2=80 dB; mapped to 85 dB on/off, 80 dB left unexposed) - Battery: group 0x0A / item 0x1A -> value 01. Percentage is level / (level_count - 1) * 100 (Poly's own formula, verified live at 70% and 60%); the remaining talk time is reported as time-to-empty. SETs are verified against the device's "changed" ack. The headset only answers while it has an active Bluetooth link to the dongle, so read timeouts are reported as device-offline. macOS support is left disabled: the dongle exposes five top-level HID collections and macOS opens only the first enumerated one, which is untested with this device. Includes unit tests for battery reply parsing and reply matching (using frames captured from the hardware) plus a registry lookup test, and the regenerated README supported-devices row. Derived from USBPcap/usbmon captures of Plantronics Hub and Poly Studio and validated live against the hardware (headset firmware v2120). --- README.md | 3 +- lib/device_registry.cpp | 6 + lib/devices/plantronics_bt600.hpp | 429 ++++++++++++++++++++++++++++++ tests/test_device_registry.cpp | 20 ++ tests/test_protocols.cpp | 120 ++++++++- 5 files changed, 567 insertions(+), 11 deletions(-) create mode 100644 lib/devices/plantronics_bt600.hpp 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);