diff --git a/lib/device_registry.cpp b/lib/device_registry.cpp index 748a60a..5babde1 100644 --- a/lib/device_registry.cpp +++ b/lib/device_registry.cpp @@ -11,6 +11,7 @@ #include "devices/logitech_g633_g933_935.hpp" #include "devices/logitech_g930.hpp" #include "devices/logitech_gpro.hpp" +#include "devices/logitech_gpro_x2_lightspeed.hpp" #include "devices/logitech_zone_wired.hpp" // SteelSeries devices with SteelSeries protocol templates @@ -86,6 +87,7 @@ void DeviceRegistry::initialize() registerDevice(std::make_unique()); registerDevice(std::make_unique()); registerDevice(std::make_unique()); + registerDevice(std::make_unique()); registerDevice(std::make_unique()); registerDevice(std::make_unique()); diff --git a/lib/devices/logitech_gpro.hpp b/lib/devices/logitech_gpro.hpp index a34ed18..743902f 100644 --- a/lib/devices/logitech_gpro.hpp +++ b/lib/devices/logitech_gpro.hpp @@ -10,7 +10,7 @@ using namespace std::string_view_literals; namespace headsetcontrol { /** - * @brief Logitech G PRO Gaming Headsets (Pro, Pro X, Pro X2) + * @brief Logitech G PRO Gaming Headsets (Pro, Pro X, Pro X2 HID++) * * Features: * - Battery status with voltage reporting @@ -32,7 +32,7 @@ class LogitechGPro : public protocols::HIDPPDevice { 0x0aaa, // G PRO X variant 0 0x0aba, // G PRO X variant 1 0x0afb, // G PRO X2 variant 0 - 0x0afc // G PRO X2 variant 1 + 0x0afc // G PRO X2 variant 1 }; } diff --git a/lib/devices/logitech_gpro_x2_lightspeed.hpp b/lib/devices/logitech_gpro_x2_lightspeed.hpp new file mode 100644 index 0000000..27a9e7d --- /dev/null +++ b/lib/devices/logitech_gpro_x2_lightspeed.hpp @@ -0,0 +1,225 @@ +#pragma once + +#include "../utility.hpp" +#include "hid_device.hpp" +#include +#include +#include +#include + +using namespace std::string_view_literals; + +namespace headsetcontrol { + +/** + * @brief Logitech G PRO X 2 LIGHTSPEED (PID 0x0af7) + * + * This variant uses a vendor-specific 64-byte protocol on usage page 0xffa0 + * for battery data instead of HID++. + */ +class LogitechGProX2Lightspeed : public HIDDevice { +public: + static constexpr std::array SUPPORTED_PRODUCT_IDS { 0x0af7 }; + static constexpr size_t PACKET_SIZE = 64; + static constexpr uint8_t REPORT_PREFIX = 0x51; + + constexpr uint16_t getVendorId() const override + { + return VENDOR_LOGITECH; + } + + std::vector getProductIds() const override + { + return { SUPPORTED_PRODUCT_IDS.begin(), SUPPORTED_PRODUCT_IDS.end() }; + } + + std::string_view getDeviceName() const override + { + return "Logitech G PRO X 2 LIGHTSPEED"sv; + } + + constexpr int getCapabilities() const override + { + return B(CAP_SIDETONE) | B(CAP_BATTERY_STATUS) | B(CAP_INACTIVE_TIME); + } + + constexpr capability_detail getCapabilityDetail(enum capabilities cap) const override + { + switch (cap) { + case CAP_BATTERY_STATUS: + case CAP_SIDETONE: + case CAP_INACTIVE_TIME: + return { .usagepage = 0xffa0, .usageid = 0x0001, .interface_id = 3 }; + default: + return HIDDevice::getCapabilityDetail(cap); + } + } + + Result getBattery(hid_device* device_handle) override + { + auto start_time = std::chrono::steady_clock::now(); + + std::array request = buildBatteryRequest(); + if (auto write_result = writeHID(device_handle, request, PACKET_SIZE); !write_result) { + return write_result.error(); + } + + std::vector raw_packets; + raw_packets.reserve(PACKET_SIZE * 4); + + for (int attempt = 0; attempt < 4; ++attempt) { + std::array response {}; + auto read_result = readHIDTimeout(device_handle, response, hsc_device_timeout); + if (!read_result) { + return read_result.error(); + } + + raw_packets.insert(raw_packets.end(), response.begin(), response.end()); + + if (isPowerOffPacket(response)) { + return DeviceError::deviceOffline("Headset is powered off or not connected"); + } + + if (isPowerEventPacket(response)) { + continue; + } + + if (isAckPacket(response)) { + continue; + } + + if (!isBatteryResponsePacket(response)) { + continue; + } + + auto battery_result = parseBatteryResponse(response); + if (!battery_result) { + return battery_result.error(); + } + + battery_result->raw_data = std::move(raw_packets); + auto end_time = std::chrono::steady_clock::now(); + battery_result->query_duration = std::chrono::duration_cast(end_time - start_time); + return *battery_result; + } + + return DeviceError::protocolError("Battery response packet not received"); + } + + Result setSidetone(hid_device* device_handle, uint8_t level) override + { + uint8_t mapped = map(level, 0, 128, 0, 100); + + auto command = buildSidetoneCommand(mapped); + if (auto write_result = writeHID(device_handle, command, PACKET_SIZE); !write_result) { + return write_result.error(); + } + + return SidetoneResult { + .current_level = level, + .min_level = 0, + .max_level = 128, + .device_min = 0, + .device_max = 100 + }; + } + + Result setInactiveTime(hid_device* device_handle, uint8_t minutes) override + { + auto command = buildInactiveTimeCommand(minutes); + if (auto write_result = writeHID(device_handle, command, PACKET_SIZE); !write_result) { + return write_result.error(); + } + + return InactiveTimeResult { + .minutes = minutes, + .min_minutes = 0, + .max_minutes = 90 + }; + } + + static constexpr bool isAckPacket(std::span packet) + { + return packet.size() >= 2 && packet[0] == REPORT_PREFIX && packet[1] == 0x03; + } + + static constexpr bool isPowerOffPacket(std::span packet) + { + return packet.size() >= 7 && packet[0] == REPORT_PREFIX && packet[1] == 0x05 && packet[6] == 0x00; + } + + static constexpr bool isPowerEventPacket(std::span packet) + { + return packet.size() >= 2 && packet[0] == REPORT_PREFIX && packet[1] == 0x05; + } + + static constexpr bool isBatteryResponsePacket(std::span packet) + { + return packet.size() >= 13 && packet[0] == REPORT_PREFIX && packet[1] == 0x0b && packet[8] == 0x04; + } + + static Result parseBatteryResponse(std::span packet) + { + if (!isBatteryResponsePacket(packet)) { + return DeviceError::protocolError("Unexpected battery response packet"); + } + + int level = static_cast(packet[10]); + if (level > 100) { + return DeviceError::protocolError("Battery percentage out of range"); + } + + auto status = packet[12] == 0x02 ? BATTERY_CHARGING : BATTERY_AVAILABLE; + + BatteryResult result { + .level_percent = level, + .status = status, + }; + + return result; + } + +private: + static constexpr std::array buildBatteryRequest() + { + std::array request {}; + request[0] = REPORT_PREFIX; + request[1] = 0x08; + request[3] = 0x03; + request[4] = 0x1a; + request[6] = 0x03; + request[8] = 0x04; + request[9] = 0x0a; + return request; + } + + static constexpr std::array buildSidetoneCommand(uint8_t level) + { + std::array command {}; + command[0] = REPORT_PREFIX; + command[1] = 0x0a; + command[3] = 0x03; + command[4] = 0x1b; + command[6] = 0x03; + command[8] = 0x07; + command[9] = 0x1b; + command[10] = level; + return command; + } + + static constexpr std::array buildInactiveTimeCommand(uint8_t minutes) + { + std::array command {}; + command[0] = REPORT_PREFIX; + command[1] = 0x09; + command[3] = 0x03; + command[4] = 0x1c; + command[6] = 0x03; + command[8] = 0x06; + command[9] = 0x1d; + command[10] = minutes; + return command; + } +}; + +} // namespace headsetcontrol diff --git a/tests/test_device_registry.cpp b/tests/test_device_registry.cpp index 1fae3ed..8bbf594 100644 --- a/tests/test_device_registry.cpp +++ b/tests/test_device_registry.cpp @@ -199,6 +199,20 @@ void testLookupWithWrongVendorId() std::cout << " OK lookup with wrong vendor ID" << std::endl; } +void testLookupLogitechProX2Lightspeed() +{ + std::cout << " Testing lookup of Logitech PRO X2 LIGHTSPEED (0x0af7)..." << std::endl; + + auto& registry = DeviceRegistry::instance(); + registry.initialize(); + + auto* device = registry.getDevice(0x046d, 0x0af7); + ASSERT_NOT_NULL(device, "Logitech PRO X2 LIGHTSPEED should be found"); + ASSERT_EQ("Logitech G PRO X 2 LIGHTSPEED", std::string(device->getDeviceName()), "Device name should match"); + + std::cout << " OK lookup Logitech PRO X2 LIGHTSPEED" << std::endl; +} + // ============================================================================ // Device Enumeration Tests // ============================================================================ @@ -449,6 +463,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 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 3c779f7..1f80a25 100644 --- a/tests/test_protocols.cpp +++ b/tests/test_protocols.cpp @@ -11,6 +11,7 @@ #include "device.hpp" #include "devices/corsair_device.hpp" +#include "devices/logitech_gpro_x2_lightspeed.hpp" #include "devices/protocols/hidpp_protocol.hpp" #include "devices/protocols/logitech_calibrations.hpp" #include "devices/protocols/steelseries_protocol.hpp" @@ -246,6 +247,94 @@ void testHIDPPOfflineDetection() std::cout << " [OK] HID++ offline detection verified" << std::endl; } +void testLogitechProX2BatteryPacketParsing() +{ + std::cout << " Testing Logitech PRO X2 vendor battery parsing..." << std::endl; + + std::array response {}; + response[0] = 0x51; + response[1] = 0x0b; + response[8] = 0x04; + response[10] = 87; + response[12] = 0x02; + + ASSERT_TRUE(LogitechGProX2Lightspeed::isBatteryResponsePacket(response), "Should identify battery response packet"); + + auto result = LogitechGProX2Lightspeed::parseBatteryResponse(response); + ASSERT_TRUE(result.hasValue(), "Battery response should parse successfully"); + 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; + 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"); + + std::cout << " [OK] Logitech PRO X2 battery packet parsing verified" << std::endl; +} + +void testLogitechProX2PowerEventDetection() +{ + std::cout << " Testing Logitech PRO X2 power event detection..." << std::endl; + + // Power-off event: byte[1]==0x05 and byte[6]==0x00 + std::array power_off {}; + power_off[0] = 0x51; + power_off[1] = 0x05; + power_off[6] = 0x00; + + ASSERT_TRUE(LogitechGProX2Lightspeed::isPowerOffPacket(power_off), "Power-off event should be detected"); + ASSERT_TRUE(LogitechGProX2Lightspeed::isPowerEventPacket(power_off), "Power-off should also be a power event"); + + // Power-on event: byte[1]==0x05 and byte[6]==0x01 + std::array power_on {}; + power_on[0] = 0x51; + power_on[1] = 0x05; + power_on[6] = 0x01; + + ASSERT_TRUE(!LogitechGProX2Lightspeed::isPowerOffPacket(power_on), "Power-on should not be detected as power-off"); + ASSERT_TRUE(LogitechGProX2Lightspeed::isPowerEventPacket(power_on), "Power-on should be detected as power event"); + + // ACK packet should not be a power event + std::array ack {}; + ack[0] = 0x51; + ack[1] = 0x03; + + ASSERT_TRUE(LogitechGProX2Lightspeed::isAckPacket(ack), "ACK packet should be detected"); + ASSERT_TRUE(!LogitechGProX2Lightspeed::isPowerEventPacket(ack), "ACK packet should not be treated as power event"); + + std::cout << " [OK] Logitech PRO X2 power event detection verified" << std::endl; +} + +void testLogitechProX2BatteryOutOfRange() +{ + std::cout << " Testing Logitech PRO X2 battery out-of-range rejection..." << std::endl; + + std::array response {}; + response[0] = 0x51; + response[1] = 0x0b; + response[8] = 0x04; + response[10] = 101; // Out of range + response[12] = 0x00; + + auto result = LogitechGProX2Lightspeed::parseBatteryResponse(response); + ASSERT_TRUE(!result.hasValue(), "Battery level 101 should be rejected as out of range"); + + // Boundary: 100 should be valid + 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; + 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"); + + std::cout << " [OK] Logitech PRO X2 battery out-of-range rejection verified" << std::endl; +} + // ============================================================================ // SteelSeries Protocol Tests // ============================================================================ @@ -491,6 +580,9 @@ void runAllProtocolTests() runTest("HID++ Voltage To Percent", testHIDPPVoltageToPercent); runTest("HID++ Battery Response", testHIDPPBatteryResponseParsing); runTest("HID++ Offline Detection", testHIDPPOfflineDetection); + runTest("Logitech PRO X2 Battery Parsing", testLogitechProX2BatteryPacketParsing); + runTest("Logitech PRO X2 Power Event", testLogitechProX2PowerEventDetection); + runTest("Logitech PRO X2 Battery Out-of-Range", testLogitechProX2BatteryOutOfRange); std::cout << "\n=== SteelSeries Protocol ===" << std::endl; runTest("SteelSeries Packet Sizes", testSteelSeriesPacketSizes);