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
2 changes: 2 additions & 0 deletions lib/device_registry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,6 +87,7 @@ void DeviceRegistry::initialize()
registerDevice(std::make_unique<LogitechG633Family>());
registerDevice(std::make_unique<LogitechG432>());
registerDevice(std::make_unique<LogitechG930>());
registerDevice(std::make_unique<LogitechGProX2Lightspeed>());
registerDevice(std::make_unique<LogitechGPro>());
registerDevice(std::make_unique<LogitechZoneWired>());

Expand Down
4 changes: 2 additions & 2 deletions lib/devices/logitech_gpro.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,7 +32,7 @@ class LogitechGPro : public protocols::HIDPPDevice<LogitechGPro> {
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
};
}

Expand Down
225 changes: 225 additions & 0 deletions lib/devices/logitech_gpro_x2_lightspeed.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
#pragma once

#include "../utility.hpp"
#include "hid_device.hpp"
#include <array>
#include <chrono>
#include <string_view>
#include <vector>

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<uint16_t, 1> 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<uint16_t> 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<BatteryResult> getBattery(hid_device* device_handle) override
{
auto start_time = std::chrono::steady_clock::now();

std::array<uint8_t, PACKET_SIZE> request = buildBatteryRequest();
if (auto write_result = writeHID(device_handle, request, PACKET_SIZE); !write_result) {
return write_result.error();
}

std::vector<uint8_t> raw_packets;
raw_packets.reserve(PACKET_SIZE * 4);

for (int attempt = 0; attempt < 4; ++attempt) {
std::array<uint8_t, PACKET_SIZE> 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<std::chrono::milliseconds>(end_time - start_time);
return *battery_result;
}

return DeviceError::protocolError("Battery response packet not received");
}

Result<SidetoneResult> setSidetone(hid_device* device_handle, uint8_t level) override
{
uint8_t mapped = map<uint8_t>(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<InactiveTimeResult> 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<const uint8_t> packet)
{
return packet.size() >= 2 && packet[0] == REPORT_PREFIX && packet[1] == 0x03;
}

static constexpr bool isPowerOffPacket(std::span<const uint8_t> packet)
{
return packet.size() >= 7 && packet[0] == REPORT_PREFIX && packet[1] == 0x05 && packet[6] == 0x00;
}

static constexpr bool isPowerEventPacket(std::span<const uint8_t> packet)
{
return packet.size() >= 2 && packet[0] == REPORT_PREFIX && packet[1] == 0x05;
}

static constexpr bool isBatteryResponsePacket(std::span<const uint8_t> packet)
{
return packet.size() >= 13 && packet[0] == REPORT_PREFIX && packet[1] == 0x0b && packet[8] == 0x04;
}

static Result<BatteryResult> parseBatteryResponse(std::span<const uint8_t> packet)
{
if (!isBatteryResponsePacket(packet)) {
return DeviceError::protocolError("Unexpected battery response packet");
}

int level = static_cast<int>(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<uint8_t, PACKET_SIZE> buildBatteryRequest()
{
std::array<uint8_t, PACKET_SIZE> 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<uint8_t, PACKET_SIZE> buildSidetoneCommand(uint8_t level)
{
std::array<uint8_t, PACKET_SIZE> 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<uint8_t, PACKET_SIZE> buildInactiveTimeCommand(uint8_t minutes)
{
std::array<uint8_t, PACKET_SIZE> 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
15 changes: 15 additions & 0 deletions tests/test_device_registry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down Expand Up @@ -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);
Expand Down
Loading