diff --git a/README.md b/README.md index 266642b..d9ad0a8 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ A cross-platform tool to control USB gaming headsets on **Linux**, **macOS**, an | Logitech G633/G635/G733/G933/G935 | All | x | x | | x | | | | | | | | | | | | | | Logitech G431/G432/G433 | All | x | | | | | | | | | | | | | | | | | Logitech G930 | All | x | x | | | | | | | | | | | | | | | -| Logitech G PRO X 2 LIGHTSPEED | All | x | x | | | x | | | | | | | | | | | | +| Logitech G PRO X 2 LIGHTSPEED | All | x | x | | | x | | | | x | x | x | | | | | | | Logitech G PRO Series | All | x | x | | | x | | | | | | | | | | | | | Logitech Zone Wired/Zone 750 | All | x | | | | | | x | x | | | | | | | | | | Corsair Headset Device | All | x | x | x | x | | | | | | | | | | | | | diff --git a/docs/LIBRARY_USAGE.md b/docs/LIBRARY_USAGE.md index 90c684e..cf8afc0 100644 --- a/docs/LIBRARY_USAGE.md +++ b/docs/LIBRARY_USAGE.md @@ -49,19 +49,19 @@ headsetcontrol -o env ### Status Values -| Status | Description | -|--------|-------------| +| Status | Description | +| --------- | ------------------------------ | | `success` | All queries completed normally | -| `partial` | Some queries failed | -| `failure` | Device communication failed | +| `partial` | Some queries failed | +| `failure` | Device communication failed | ### Battery Status Values -| Status | Description | -|--------|-------------| -| `BATTERY_AVAILABLE` | Battery level available | -| `BATTERY_CHARGING` | Currently charging (level may be -1) | -| `BATTERY_UNAVAILABLE` | Device unavailable/off | +| Status | Description | +| --------------------- | ------------------------------------ | +| `BATTERY_AVAILABLE` | Battery level available | +| `BATTERY_CHARGING` | Currently charging (level may be -1) | +| `BATTERY_UNAVAILABLE` | Device unavailable/off | ### Performing Actions @@ -73,24 +73,28 @@ headsetcontrol -s 64 -b -o json ``` Action results include status: + ```json { - "devices": [{ - "sidetone": { - "status": "success", - "level": 64 - }, - "battery": { - "status": "BATTERY_AVAILABLE", - "level": 85 + "devices": [ + { + "sidetone": { + "status": "success", + "level": 64 + }, + "battery": { + "status": "BATTERY_AVAILABLE", + "level": 85 + } } - }] + ] } ``` ### API Versioning The `api_version` field uses semantic versioning: + - First number increments on **breaking changes** - Second number increments on **additions** @@ -327,6 +331,12 @@ if (headset.supports(CAP_EQUALIZER_PRESET)) { headset.setEqualizerPreset(2); // Preset #2 int presetCount = headset.getEqualizerPresetsCount(); + + if (auto presets = headset.getEqualizerPresets()) { + for (const auto& preset : presets->presets) { + std::cout << preset.name << ": " << preset.values.size() << " bands\n"; + } + } } // Custom EQ curve @@ -535,6 +545,7 @@ int count = hsc_discover(&headsets); // Includes test device ``` The test device implements all capabilities and returns predictable values, making it useful for: + - Testing library integration - Developing GUI applications - CI/CD pipelines @@ -550,20 +561,24 @@ The test device implements all capabilities and returns predictable values, maki ## Platform Notes ### Linux + - Requires udev rules for non-root access - Generate with: `headsetcontrol -u > /etc/udev/rules.d/70-headset.rules` - Reload: `sudo udevadm control --reload-rules && sudo udevadm trigger` ### macOS + - No special permissions needed ### Windows + - Uses SetupAPI for device access - May require running as Administrator for some devices ## Dependencies When linking manually, you need: + - **HIDAPI**: `libhidapi` (automatically linked when using CMake subdirectory) - **Math library**: `-lm` on Linux/macOS diff --git a/lib/devices/logitech_gpro_x2_lightspeed.hpp b/lib/devices/logitech_gpro_x2_lightspeed.hpp index 37a5879..6740ed4 100644 --- a/lib/devices/logitech_gpro_x2_lightspeed.hpp +++ b/lib/devices/logitech_gpro_x2_lightspeed.hpp @@ -1,9 +1,11 @@ #pragma once #include "../utility.hpp" -#include "hid_device.hpp" +#include "protocols/logitech_centurion_protocol.hpp" +#include #include #include +#include #include #include @@ -17,11 +19,15 @@ namespace headsetcontrol { * This variant uses a vendor-specific 64-byte protocol on usage page 0xffa0 * for battery data instead of HID++. */ -class LogitechGProX2Lightspeed : public HIDDevice { +class LogitechGProX2Lightspeed : public protocols::LogitechCenturionProtocol { public: static constexpr std::array SUPPORTED_PRODUCT_IDS { 0x0af7 }; static constexpr size_t PACKET_SIZE = 64; static constexpr uint8_t REPORT_PREFIX = 0x51; + static constexpr uint8_t SIDETONE_DEVICE_MAX = 100; + static constexpr uint8_t SIDETONE_MIC_ID = 0x01; + static constexpr uint8_t PLAYBACK_DIRECTION = 0x00; + static constexpr uint8_t EQUALIZER_PRESETS_COUNT = 5; constexpr uint16_t getVendorId() const override { @@ -40,7 +46,62 @@ class LogitechGProX2Lightspeed : public HIDDevice { constexpr int getCapabilities() const override { - return B(CAP_SIDETONE) | B(CAP_BATTERY_STATUS) | B(CAP_INACTIVE_TIME); + return B(CAP_SIDETONE) | B(CAP_BATTERY_STATUS) | B(CAP_INACTIVE_TIME) + | B(CAP_EQUALIZER_PRESET) + | B(CAP_EQUALIZER) | B(CAP_PARAMETRIC_EQUALIZER); + } + + uint8_t getEqualizerPresetsCount() const override + { + return EQUALIZER_PRESETS_COUNT; + } + + std::optional getEqualizerPresets() const override + { + EqualizerPresets presets; + presets.presets = { + { "Flat", std::vector(PRESET_FLAT.begin(), PRESET_FLAT.end()) }, + { "Bass Boost", std::vector(PRESET_BASS_BOOST.begin(), PRESET_BASS_BOOST.end()) }, + { "Team Chat", std::vector(PRESET_TEAM_CHAT.begin(), PRESET_TEAM_CHAT.end()) }, + { "Shooter", std::vector(PRESET_SHOOTER.begin(), PRESET_SHOOTER.end()) }, + { "MOBA", std::vector(PRESET_MOBA.begin(), PRESET_MOBA.end()) }, + }; + return presets; + } + + std::optional getEqualizerInfo() const override + { + if (!has_cached_equalizer_info_) { + return std::nullopt; + } + + return EqualizerInfo { + .bands_count = cached_band_count_, + .bands_baseline = 0, + .bands_step = 1.0f, + .bands_min = cached_gain_min_, + .bands_max = cached_gain_max_ + }; + } + + std::optional getParametricEqualizerInfo() const override + { + if (!has_cached_equalizer_info_) { + return std::nullopt; + } + + return ParametricEqualizerInfo { + .bands_count = cached_band_count_, + .gain_base = 0.0f, + .gain_step = 1.0f, + .gain_min = static_cast(cached_gain_min_), + .gain_max = static_cast(cached_gain_max_), + .q_factor_min = 1.0f, + .q_factor_max = 1.0f, + .freq_min = 20, + .freq_max = 20000, + .filter_types = B(static_cast(EqualizerFilterType::Peaking)) + }; } constexpr capability_detail getCapabilityDetail(enum capabilities cap) const override @@ -49,6 +110,9 @@ class LogitechGProX2Lightspeed : public HIDDevice { case CAP_BATTERY_STATUS: case CAP_SIDETONE: case CAP_INACTIVE_TIME: + case CAP_EQUALIZER_PRESET: + case CAP_EQUALIZER: + case CAP_PARAMETRIC_EQUALIZER: return { .usagepage = 0xffa0, .usageid = 0x0001, .interface_id = 3 }; default: return HIDDevice::getCapabilityDetail(cap); @@ -57,6 +121,24 @@ class LogitechGProX2Lightspeed : public HIDDevice { Result getBattery(hid_device* device_handle) override { + auto centurion_start_time = std::chrono::steady_clock::now(); + if (auto centurion_battery = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::CenturionBatterySoc), + 0x00); + centurion_battery) { + auto battery_result = parseCenturionBatteryResponse(*centurion_battery); + if (!battery_result) { + return battery_result.error(); + } + + battery_result->raw_data = *centurion_battery; + auto centurion_end_time = std::chrono::steady_clock::now(); + battery_result->query_duration = std::chrono::duration_cast( + centurion_end_time - centurion_start_time); + return *battery_result; + } + auto start_time = std::chrono::steady_clock::now(); std::array request = buildBatteryRequest(); @@ -69,8 +151,7 @@ class LogitechGProX2Lightspeed : public HIDDevice { for (int attempt = 0; attempt < 4; ++attempt) { std::array response {}; - auto read_result = readHIDTimeout(device_handle, response, hsc_device_timeout); - if (!read_result) { + if (auto read_result = readHIDTimeout(device_handle, response, hsc_device_timeout); !read_result) { return read_result.error(); } @@ -108,23 +189,29 @@ class LogitechGProX2Lightspeed : public HIDDevice { Result setSidetone(hid_device* device_handle, uint8_t level) override { + uint8_t mapped = map(level, 0, 128, 0, SIDETONE_DEVICE_MAX); + + auto sidetone_state = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetAudioSidetone), + 0x00); + if (!sidetone_state) { + return sidetone_state.error(); + } - // INFO: The original G HUB app does some strange mapping: - // 0 - 5 -> 0x00 (off) - // 6 - 16 -> 0x01 - // 17 - 27 -> 0x02 - // 28 - 38 -> 0x03 - // 39 - 49 -> 0x04 - // 50 - 61 -> 0x05 - // 62 - 72 -> 0x06 - // 73 - 83 -> 0x07 - // 84 - 94 -> 0x08 - // 95 - 100 -> 0x09 - uint8_t mapped = map(level, 0, 128, 0, 9); - - auto command = buildSidetoneCommand(mapped); - if (auto write_result = writeHID(device_handle, command, PACKET_SIZE); !write_result) { - return write_result.error(); + std::array version2_payload { SIDETONE_MIC_ID, 0xFF, mapped }; + std::array version1_payload { SIDETONE_MIC_ID, mapped }; + std::span payload = sidetone_state->size() >= 4 + ? std::span(version2_payload) + : std::span(version1_payload); + + if (auto sidetone_write = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetAudioSidetone), + 0x10, + payload); + !sidetone_write) { + return sidetone_write.error(); } return SidetoneResult { @@ -132,21 +219,134 @@ class LogitechGProX2Lightspeed : public HIDDevice { .min_level = 0, .max_level = 128, .device_min = 0, - .device_max = 9 + .device_max = SIDETONE_DEVICE_MAX, + .is_muted = level == 0 }; } + Result setEqualizer(hid_device* device_handle, const EqualizerSettings& settings) override + { + auto descriptor = readEqualizerDescriptor(device_handle); + if (!descriptor) { + return descriptor.error(); + } + + if (settings.size() != static_cast(descriptor->bands.size())) { + return DeviceError::invalidParameter("Equalizer requires one gain value per playback band"); + } + + std::vector bands; + bands.reserve(descriptor->bands.size()); + + for (size_t i = 0; i < descriptor->bands.size(); ++i) { + float gain = settings.bands[i]; + if (gain < descriptor->gain_min || gain > descriptor->gain_max) { + return DeviceError::invalidParameter("Equalizer gain is outside the supported range"); + } + + bands.push_back(AdvancedEqBand { + .frequency = descriptor->bands[i].frequency, + .gain_db = encodeGain(gain) + }); + } + + if (auto write_result = writePlaybackAdvancedEq(device_handle, *descriptor, bands); !write_result) { + return write_result.error(); + } + + return EqualizerResult {}; + } + + Result setEqualizerPreset(hid_device* device_handle, uint8_t preset) override + { + if (preset >= EQUALIZER_PRESETS_COUNT) { + return DeviceError::invalidParameter("Device only supports presets 0-4"); + } + + static constexpr std::array*, EQUALIZER_PRESETS_COUNT> PRESET_VALUES { + &PRESET_FLAT, + &PRESET_BASS_BOOST, + &PRESET_TEAM_CHAT, + &PRESET_SHOOTER, + &PRESET_MOBA, + }; + const auto* preset_values = PRESET_VALUES[preset]; + + EqualizerSettings settings; + settings.bands.assign(preset_values->begin(), preset_values->end()); + + if (auto result = setEqualizer(device_handle, settings); !result) { + return result.error(); + } + + return EqualizerPresetResult { + .preset = preset, + .total_presets = EQUALIZER_PRESETS_COUNT + }; + } + + Result setParametricEqualizer( + hid_device* device_handle, + const ParametricEqualizerSettings& settings) override + { + auto descriptor = readEqualizerDescriptor(device_handle); + if (!descriptor) { + return descriptor.error(); + } + + if (settings.size() != static_cast(descriptor->bands.size())) { + return DeviceError::invalidParameter("Parametric equalizer requires exactly one entry per playback band"); + } + + std::vector bands; + bands.reserve(descriptor->bands.size()); + + for (const auto& band : settings.bands) { + if (band.type != EqualizerFilterType::Peaking) { + return DeviceError::invalidParameter("This headset only supports peaking EQ bands"); + } + + if (std::fabs(band.q_factor - 1.0f) > 0.001f) { + return DeviceError::invalidParameter("This headset uses a fixed Q factor of 1.0"); + } + + if (band.frequency < 20.0f || band.frequency > 20000.0f) { + return DeviceError::invalidParameter("Frequency must be between 20 Hz and 20000 Hz"); + } + + if (band.gain < descriptor->gain_min || band.gain > descriptor->gain_max) { + return DeviceError::invalidParameter("Gain is outside the supported range"); + } + + bands.push_back(AdvancedEqBand { + .frequency = static_cast(band.frequency), + .gain_db = encodeGain(band.gain), + .q_factor = static_cast(std::clamp(std::lround(band.q_factor), 1, 255)) + }); + } + + if (auto write_result = writePlaybackAdvancedEq(device_handle, *descriptor, bands); !write_result) { + return write_result.error(); + } + + return ParametricEqualizerResult {}; + } + 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) { + if (auto write_result = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::CenturionAutoSleep), + 0x10, + std::array { minutes }); + !write_result) { return write_result.error(); } return InactiveTimeResult { .minutes = minutes, .min_minutes = 0, - .max_minutes = 90 + .max_minutes = 255 }; } @@ -176,7 +376,7 @@ class LogitechGProX2Lightspeed : public HIDDevice { return DeviceError::protocolError("Unexpected battery response packet"); } - int level = static_cast(packet[10]); + auto level = static_cast(packet[10]); if (level > 100) { return DeviceError::protocolError("Battery percentage out of range"); } @@ -191,7 +391,43 @@ class LogitechGProX2Lightspeed : public HIDDevice { return result; } + static Result parseCenturionBatteryResponse(std::span packet) + { + if (packet.empty()) { + return DeviceError::protocolError("Empty Centurion battery response"); + } + + auto level = static_cast(packet[0]); + if (level < 0 || level > 100) { + return DeviceError::protocolError("Centurion battery percentage out of range"); + } + + auto charging_state = packet.size() >= 3 ? packet[2] : 0; + // Centurion battery replies use states 1 and 2 for charging; the legacy packet parser only treats 0x02 as charging. + auto status = (charging_state == 1 || charging_state == 2) + ? BATTERY_CHARGING + : BATTERY_AVAILABLE; + + return BatteryResult { + .level_percent = level, + .status = status, + }; + } + private: + enum class EqualizerBackend { + AdvancedParametric, + Onboard + }; + + // These presets match the values shown in Logitech G Hub for the 5-band + // playback EQ at 80, 240, 750, 2200, and 6600 Hz. + static constexpr std::array PRESET_FLAT { 0.0f, 0.0f, 0.0f, 0.0f, 0.0f }; + static constexpr std::array PRESET_BASS_BOOST { 4.0f, 2.0f, 0.0f, 0.0f, 0.0f }; + static constexpr std::array PRESET_TEAM_CHAT { -1.0f, 2.0f, 1.0f, 3.0f, 3.0f }; + static constexpr std::array PRESET_SHOOTER { -1.0f, -1.0f, 4.0f, 3.0f, 2.0f }; + static constexpr std::array PRESET_MOBA { 0.0f, 1.0f, 1.0f, 2.0f, 4.0f }; + static constexpr std::array buildBatteryRequest() { std::array request {}; @@ -205,33 +441,367 @@ class LogitechGProX2Lightspeed : public HIDDevice { return request; } - static constexpr std::array buildSidetoneCommand(uint8_t level) + struct AdvancedEqBand { + uint16_t frequency = 0; + int8_t gain_db = 0; + uint8_t q_factor = 1; + }; + + struct AdvancedEqDescriptor { + EqualizerBackend backend = EqualizerBackend::AdvancedParametric; + uint8_t active_slot = 0; + float gain_min = -12.0f; + float gain_max = 12.0f; + std::vector bands; + }; + +public: + static std::vector buildOnboardEqPayloadForTest( + uint8_t slot, + const std::vector>& bands) + { + std::vector converted; + converted.reserve(bands.size()); + for (const auto& [frequency, gain_db, q_factor] : bands) { + converted.push_back(AdvancedEqBand { + .frequency = frequency, + .gain_db = gain_db, + .q_factor = q_factor + }); + } + return buildOnboardEqPayload(slot, converted); + } + + static std::vector quantizeOnboardEqCoefficientsForTest(const std::array& coeffs) + { + return quantizeOnboardEqCoefficients(coeffs); + } + +private: + + void cacheEqualizerInfo(const AdvancedEqDescriptor& descriptor) const { - 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; + cached_band_count_ = static_cast(descriptor.bands.size()); + cached_gain_min_ = static_cast(descriptor.gain_min); + cached_gain_max_ = static_cast(descriptor.gain_max); + has_cached_equalizer_info_ = true; } - static constexpr std::array buildInactiveTimeCommand(uint8_t minutes) + static constexpr std::array buildPlaybackEqSelector(uint8_t slot) { - 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; + return { PLAYBACK_DIRECTION, slot }; } + + static constexpr int8_t decodeSignedByte(uint8_t value) + { + return static_cast(value); + } + + static constexpr int8_t encodeGain(float gain) + { + return static_cast(gain); + } + + static std::array buildPeakingEqBiquad(double frequency, double gain_db, double q_factor, double sample_rate) + { + constexpr double pi = 3.14159265358979323846; + double amplitude = std::pow(10.0, gain_db / 40.0); + double w0 = 2.0 * pi * frequency / sample_rate; + double cos_w0 = std::cos(w0); + double alpha = std::sin(w0) / (2.0 * q_factor); + double a0 = 1.0 + alpha / amplitude; + + return { + (1.0 + alpha * amplitude) / a0, + (-2.0 * cos_w0) / a0, + (1.0 - alpha * amplitude) / a0, + (-2.0 * cos_w0) / a0, + (1.0 - alpha / amplitude) / a0 + }; + } + + static std::vector quantizeOnboardEqCoefficients(const std::array& coeffs) + { + static constexpr std::array scales { + 2147483648.0, + 1073741824.0, + 2147483648.0, + 1073741824.0, + 2147483648.0 + }; + + std::vector words; + words.reserve(10); + + for (size_t i = 0; i < coeffs.size(); ++i) { + auto q_value = static_cast(std::llround(coeffs[i] * scales[i])); + q_value = std::clamp(q_value, -(1LL << 31), (1LL << 31) - 1); + q_value &= ~INT64_C(0xFF); + words.push_back(static_cast((q_value >> 16) & 0xFFFF)); + words.push_back(static_cast(q_value & 0xFFFF)); + } + + return words; + } + + static std::vector buildOnboardEqCoefficientSection( + const std::vector& bands, + double sample_rate, + uint8_t section_type) + { + static constexpr double HEADROOM = 1.19; + + std::vector> raw_coeffs; + raw_coeffs.reserve(bands.size()); + + double max_b0 = 1.0; + for (const auto& band : bands) { + double q_factor = std::max(0.1, static_cast(band.q_factor)); + auto coeffs = buildPeakingEqBiquad(band.frequency, band.gain_db, q_factor, sample_rate); + max_b0 = std::max(max_b0, std::abs(coeffs[0])); + raw_coeffs.push_back(coeffs); + } + + double rescale = std::max(1.0, max_b0) * HEADROOM; + + std::vector words; + words.reserve(1 + bands.size() * 10 + 2); + words.push_back(static_cast(bands.size())); + + for (const auto& coeffs : raw_coeffs) { + std::array normalized { + coeffs[0] / rescale, + coeffs[1] / rescale, + coeffs[2] / rescale, + coeffs[3], + coeffs[4] + }; + + auto quantized = quantizeOnboardEqCoefficients(normalized); + words.insert(words.end(), quantized.begin(), quantized.end()); + } + + auto rescale_q = static_cast(std::llround(rescale * 67108864.0)); + rescale_q = std::clamp(rescale_q, -(1LL << 31), (1LL << 31) - 1); + rescale_q &= 0xFFFFFF00; + words.push_back(static_cast((rescale_q >> 16) & 0xFFFF)); + words.push_back(static_cast(rescale_q & 0xFFFF)); + + uint16_t coeff_count = static_cast(bands.size() * 10 + 3); + std::vector section { + section_type, + 0x00, + static_cast(coeff_count & 0xFF), + static_cast((coeff_count >> 8) & 0xFF) + }; + + section.reserve(section.size() + words.size() * 2); + for (uint16_t word : words) { + section.push_back(static_cast(word & 0xFF)); + section.push_back(static_cast((word >> 8) & 0xFF)); + } + + return section; + } + + static std::vector buildOnboardEqPayload(uint8_t slot, const std::vector& bands) + { + std::vector payload { + slot, + static_cast(bands.size()) + }; + + for (const auto& band : bands) { + payload.push_back(static_cast((band.frequency >> 8) & 0xFF)); + payload.push_back(static_cast(band.frequency & 0xFF)); + payload.push_back(static_cast(band.gain_db)); + payload.push_back(band.q_factor); + } + + payload.insert(payload.end(), { 0x05, 0x5A, 0xE3, 0x00 }); + payload.insert(payload.end(), { 0x03, 0x0E, 0x00, 0x02, 0x00, 0x00, 0x00 }); + + auto playback_section = buildOnboardEqCoefficientSection(bands, 48000.0, 0x01); + payload.insert(payload.end(), playback_section.begin(), playback_section.end()); + + auto microphone_section = buildOnboardEqCoefficientSection(bands, 16000.0, 0x02); + payload.insert(payload.end(), microphone_section.begin(), microphone_section.end()); + + return payload; + } + + Result readAdvancedEqDescriptor(hid_device* device_handle) const + { + auto info_reply = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetAdvancedParaEQ), + 0x00); + if (!info_reply) { + return info_reply.error(); + } + + if (info_reply->size() < 5) { + return DeviceError::protocolError("Advanced EQ info response was too short"); + } + + auto active_slot_reply = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetAdvancedParaEQ), + 0x30, + std::array { PLAYBACK_DIRECTION }); + if (!active_slot_reply) { + return active_slot_reply.error(); + } + if (active_slot_reply->empty()) { + return DeviceError::protocolError("Advanced EQ active slot response was empty"); + } + + auto params_reply = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetAdvancedParaEQ), + 0x10, + buildPlaybackEqSelector((*active_slot_reply)[0])); + if (!params_reply) { + return params_reply.error(); + } + + AdvancedEqDescriptor descriptor { + .backend = EqualizerBackend::AdvancedParametric, + .active_slot = (*active_slot_reply)[0], + .gain_min = static_cast(decodeSignedByte((*info_reply)[3])), + .gain_max = static_cast(decodeSignedByte((*info_reply)[4])), + }; + + for (size_t offset = 0; offset + 2 < params_reply->size(); offset += 3) { + auto frequency = static_cast( + (static_cast((*params_reply)[offset]) << 8) + | static_cast((*params_reply)[offset + 1])); + if (frequency == 0) { + break; + } + + descriptor.bands.push_back(AdvancedEqBand { + .frequency = frequency, + .gain_db = decodeSignedByte((*params_reply)[offset + 2]) + }); + } + + if (descriptor.bands.empty()) { + return DeviceError::protocolError("Advanced EQ band response was empty"); + } + + cacheEqualizerInfo(descriptor); + return descriptor; + } + + Result readOnboardEqDescriptor(hid_device* device_handle) const + { + auto info_reply = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetOnboardEQ), + 0x00); + if (!info_reply) { + return info_reply.error(); + } + + if (info_reply->size() < 5) { + return DeviceError::protocolError("Onboard EQ info response was too short"); + } + + auto params_reply = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetOnboardEQ), + 0x10, + std::array { 0x00 }); + if (!params_reply) { + return params_reply.error(); + } + + if (params_reply->size() < 2) { + return DeviceError::protocolError("Onboard EQ parameter response was too short"); + } + + AdvancedEqDescriptor descriptor { + .backend = EqualizerBackend::Onboard, + .active_slot = 0x00, + .gain_min = -12.0f, + .gain_max = 12.0f, + }; + + uint8_t band_count = (*params_reply)[1]; + size_t offset = 2; + for (uint8_t band = 0; band < band_count && offset + 3 < params_reply->size(); ++band) { + descriptor.bands.push_back(AdvancedEqBand { + .frequency = static_cast((static_cast((*params_reply)[offset]) << 8) | (*params_reply)[offset + 1]), + .gain_db = decodeSignedByte((*params_reply)[offset + 2]), + .q_factor = (*params_reply)[offset + 3] + }); + offset += 4; + } + + if (descriptor.bands.empty()) { + return DeviceError::protocolError("Onboard EQ band response was empty"); + } + + cacheEqualizerInfo(descriptor); + return descriptor; + } + + Result readEqualizerDescriptor(hid_device* device_handle) const + { + if (auto advanced = readAdvancedEqDescriptor(device_handle); advanced) { + return advanced; + } + + return readOnboardEqDescriptor(device_handle); + } + + Result writePlaybackAdvancedEq( + hid_device* device_handle, + const AdvancedEqDescriptor& descriptor, + const std::vector& bands) const + { + if (descriptor.backend == EqualizerBackend::AdvancedParametric) { + std::vector payload; + payload.reserve(2 + bands.size() * 3); + payload.push_back(PLAYBACK_DIRECTION); + payload.push_back(descriptor.active_slot); + + for (const auto& band : bands) { + payload.push_back(static_cast((band.frequency >> 8) & 0xFF)); + payload.push_back(static_cast(band.frequency & 0xFF)); + payload.push_back(static_cast(band.gain_db)); + } + + if (auto write_reply = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetAdvancedParaEQ), + 0x20, + payload); + !write_reply) { + return write_reply.error(); + } + + return {}; + } + + auto payload = buildOnboardEqPayload(descriptor.active_slot, bands); + if (auto write_reply = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetOnboardEQ), + 0x20, + payload); + !write_reply) { + return write_reply.error(); + } + + return {}; + } + + mutable bool has_cached_equalizer_info_ = false; + mutable int cached_band_count_ = 0; + mutable int cached_gain_min_ = 0; + mutable int cached_gain_max_ = 0; }; } // namespace headsetcontrol diff --git a/lib/devices/protocols/logitech_centurion_protocol.hpp b/lib/devices/protocols/logitech_centurion_protocol.hpp new file mode 100644 index 0000000..126c5dd --- /dev/null +++ b/lib/devices/protocols/logitech_centurion_protocol.hpp @@ -0,0 +1,441 @@ +#pragma once + +#include "../../device.hpp" +#include "../../result_types.hpp" +#include "../hid_device.hpp" +#include +#include +#include +#include +#include + +namespace headsetcontrol::protocols { + +enum class CenturionFeature : uint16_t { + Root = 0x0000, + FeatureSet = 0x0001, + CenturionBridge = 0x0003, + CenturionBatterySoc = 0x0104, + CenturionAutoSleep = 0x0108, + HeadsetAdvancedParaEQ = 0x020d, + HeadsetOnboardEQ = 0x0636, + HeadsetAudioSidetone = 0x0604, +}; + +struct CenturionFeatureInfo { + uint8_t index = 0; + uint8_t version = 0; + uint8_t flags = 0; +}; + +class LogitechCenturionProtocol : public HIDDevice { +protected: + static constexpr uint8_t REPORT_ID = 0x51; + static constexpr size_t FRAME_SIZE = 64; + static constexpr uint8_t SOFTWARE_ID = 0x01; + static constexpr uint8_t BRIDGE_SEND_FRAGMENT_FN = 0x10; + static constexpr uint8_t BRIDGE_MESSAGE_EVENT_FN = 0x10; + static constexpr int POLL_ATTEMPTS = 8; + static constexpr size_t MAX_SINGLE_BRIDGE_PAYLOAD = 56; + static constexpr size_t MAX_CONTINUATION_PAYLOAD = 60; + static constexpr size_t MAX_BRIDGE_SUB_MESSAGE_SIZE = 0x0FFF; + + [[nodiscard]] Result> sendCenturionRequest( + hid_device* device_handle, + uint8_t feature_index, + uint8_t function, + std::span params = {}) const + { + auto payload = buildDirectPayload(feature_index, function, params); + auto frame = buildCenturionFrame(payload); + + if (auto write_result = this->writeHID(device_handle, frame, frame.size()); !write_result) { + return write_result.error(); + } + + for (int attempt = 0; attempt < POLL_ATTEMPTS; ++attempt) { + std::array response {}; + auto read_result = this->readHIDTimeout(device_handle, response, hsc_device_timeout); + if (!read_result) { + return read_result.error(); + } + + auto payload_result = extractCenturionPayload(response); + if (!payload_result) { + return payload_result.error(); + } + + const auto& reply = *payload_result; + if (reply.size() < 2) { + continue; + } + + if (reply[0] != feature_index) { + continue; + } + + return std::vector(reply.begin() + 2, reply.end()); + } + + return DeviceError::timeout("Timed out waiting for Centurion feature response"); + } + + [[nodiscard]] Result getCenturionFeatureInfo( + hid_device* device_handle, + uint16_t feature_id) const + { + auto init_result = ensureCenturionFeaturesDiscovered(device_handle); + if (!init_result) { + return init_result.error(); + } + + auto it = centurion_sub_features_.find(feature_id); + if (it == centurion_sub_features_.end()) { + return DeviceError::notSupported("Centurion feature not available on this device"); + } + + return it->second; + } + + [[nodiscard]] Result> sendCenturionFeatureRequest( + hid_device* device_handle, + uint16_t feature_id, + uint8_t function, + std::span params = {}) const + { + auto feature_info = getCenturionFeatureInfo(device_handle, feature_id); + if (!feature_info) { + return feature_info.error(); + } + + return sendCenturionBridgeRequest(device_handle, feature_info->index, function, params); + } + +public: + static constexpr bool isBridgeSubMessageSizeSupported(size_t sub_message_size) + { + return sub_message_size <= MAX_BRIDGE_SUB_MESSAGE_SIZE; + } + + static constexpr auto buildCenturionFrame(std::span payload, uint8_t flags = 0x00) + -> std::array + { + std::array frame {}; + frame[0] = REPORT_ID; + frame[1] = static_cast(payload.size() + 1); + frame[2] = flags; + + for (size_t i = 0; i < payload.size() && (i + 3) < frame.size(); ++i) { + frame[i + 3] = payload[i]; + } + + return frame; + } + + static constexpr auto buildBridgeSubMessage( + uint8_t sub_feature_index, + uint8_t function, + std::span params, + uint8_t software_id = SOFTWARE_ID) -> std::array + { + std::array message {}; + message[0] = 0x00; + message[1] = sub_feature_index; + message[2] = static_cast((function & 0xF0) | (software_id & 0x0F)); + + for (size_t i = 0; i < params.size() && (i + 3) < message.size(); ++i) { + message[i + 3] = params[i]; + } + + return message; + } + + static auto buildBridgeSubMessageVector( + uint8_t sub_feature_index, + uint8_t function, + std::span params, + uint8_t software_id = SOFTWARE_ID) -> std::vector + { + std::vector message; + message.reserve(params.size() + 3); + message.push_back(0x00); + message.push_back(sub_feature_index); + message.push_back(static_cast((function & 0xF0) | (software_id & 0x0F))); + message.insert(message.end(), params.begin(), params.end()); + return message; + } + + static constexpr bool isBridgeResponseFor(std::span reply_data, uint8_t expected_sub_feature_index) + { + if (reply_data.size() < 6) { + return false; + } + + uint8_t sub_cpl = reply_data[4]; + uint8_t sub_feature = reply_data[5]; + if (sub_cpl != 0x00) { + return false; + } + + if (sub_feature == expected_sub_feature_index) { + return true; + } + + return sub_feature == 0xFF && reply_data.size() >= 7 && reply_data[6] == expected_sub_feature_index; + } + + static Result> parseBridgeResponse(std::span reply_data) + { + if (reply_data.size() < 7) { + return DeviceError::protocolError("Malformed Centurion bridge response"); + } + + if (reply_data[5] == 0xFF) { + return DeviceError::protocolError("Centurion sub-device rejected the request"); + } + + return std::vector(reply_data.begin() + 7, reply_data.end()); + } +private: + [[nodiscard]] Result ensureCenturionFeaturesDiscovered(hid_device* device_handle) const + { + if (centurion_features_discovered_) { + return {}; + } + + auto feature_set_lookup = sendCenturionRequest( + device_handle, + static_cast(CenturionFeature::Root), + 0x00, + std::array { + static_cast(static_cast(CenturionFeature::FeatureSet) >> 8), + static_cast(static_cast(CenturionFeature::FeatureSet) & 0xFF) + }); + if (!feature_set_lookup) { + return feature_set_lookup.error(); + } + if (feature_set_lookup->empty() || (*feature_set_lookup)[0] == 0) { + return DeviceError::protocolError("Centurion FeatureSet not found"); + } + + uint8_t feature_set_index = (*feature_set_lookup)[0]; + + auto feature_count_reply = sendCenturionRequest(device_handle, feature_set_index, 0x00); + if (!feature_count_reply) { + return feature_count_reply.error(); + } + if (feature_count_reply->empty()) { + return DeviceError::protocolError("Centurion FeatureSet count response was empty"); + } + + uint8_t feature_count = (*feature_count_reply)[0]; + uint8_t bridge_index = 0xFF; + for (uint8_t index = 0; index < feature_count; ++index) { + auto feature_reply = sendCenturionRequest(device_handle, feature_set_index, 0x10, std::array { index }); + if (!feature_reply) { + return feature_reply.error(); + } + if (feature_reply->size() < 3) { + continue; + } + + uint16_t feature_id = (static_cast((*feature_reply)[1]) << 8) | (*feature_reply)[2]; + if (feature_id == static_cast(CenturionFeature::CenturionBridge)) { + bridge_index = index; + break; + } + } + + if (bridge_index == 0xFF) { + return DeviceError::protocolError("Centurion bridge feature not found"); + } + + centurion_bridge_index_ = bridge_index; + + auto sub_feature_set_lookup = sendCenturionBridgeRequest( + device_handle, + static_cast(CenturionFeature::Root), + 0x00, + std::array { + static_cast(static_cast(CenturionFeature::FeatureSet) >> 8), + static_cast(static_cast(CenturionFeature::FeatureSet) & 0xFF) + }); + if (!sub_feature_set_lookup) { + return sub_feature_set_lookup.error(); + } + if (sub_feature_set_lookup->empty() || (*sub_feature_set_lookup)[0] == 0) { + return DeviceError::protocolError("Centurion sub-device FeatureSet not found"); + } + + uint8_t sub_feature_set_index = (*sub_feature_set_lookup)[0]; + + auto sub_feature_count_reply = sendCenturionBridgeRequest(device_handle, sub_feature_set_index, 0x00); + if (!sub_feature_count_reply) { + return sub_feature_count_reply.error(); + } + if (sub_feature_count_reply->empty()) { + return DeviceError::protocolError("Centurion sub-device FeatureSet count response was empty"); + } + + uint8_t sub_feature_count = (*sub_feature_count_reply)[0]; + centurion_sub_features_.clear(); + + for (uint8_t index = 0; index < sub_feature_count; ++index) { + auto feature_reply = sendCenturionBridgeRequest(device_handle, sub_feature_set_index, 0x10, std::array { index }); + if (!feature_reply) { + return feature_reply.error(); + } + if (feature_reply->size() < 3) { + continue; + } + + uint16_t feature_id = (static_cast((*feature_reply)[1]) << 8) | (*feature_reply)[2]; + centurion_sub_features_[feature_id] = CenturionFeatureInfo { + .index = index, + .version = static_cast(feature_reply->size() > 3 ? (*feature_reply)[3] : 0), + .flags = static_cast(feature_reply->size() > 4 ? (*feature_reply)[4] : 0), + }; + } + + centurion_features_discovered_ = true; + return {}; + } + + [[nodiscard]] Result> sendCenturionBridgeRequest( + hid_device* device_handle, + uint8_t sub_feature_index, + uint8_t function, + std::span params = {}) const + { + if (!centurion_bridge_index_.has_value()) { + return DeviceError::protocolError("Centurion bridge index not initialized"); + } + + auto sub_message = buildBridgeSubMessageVector(sub_feature_index, function, params); + const size_t sub_message_size = sub_message.size(); + if (!isBridgeSubMessageSizeSupported(sub_message_size)) { + return DeviceError::invalidParameter("Centurion bridge message exceeds the 12-bit size limit"); + } + + std::vector bridge_prefix { + *centurion_bridge_index_, + static_cast(BRIDGE_SEND_FRAGMENT_FN | SOFTWARE_ID), + static_cast((sub_message_size >> 8) & 0x0F), + static_cast(sub_message_size & 0xFF) + }; + + if (sub_message_size <= MAX_SINGLE_BRIDGE_PAYLOAD) { + std::vector layer3 = bridge_prefix; + layer3.insert(layer3.end(), sub_message.begin(), sub_message.end()); + + auto frame = buildCenturionFrame(layer3); + if (auto write_result = this->writeHID(device_handle, frame, frame.size()); !write_result) { + return write_result.error(); + } + } else { + size_t offset = 0; + uint8_t frag_idx = 0; + + while (offset < sub_message_size) { + const size_t chunk_limit = frag_idx == 0 ? MAX_SINGLE_BRIDGE_PAYLOAD : MAX_CONTINUATION_PAYLOAD; + const size_t remaining = sub_message_size - offset; + const size_t chunk_size = std::min(chunk_limit, remaining); + const bool has_more = (offset + chunk_size) < sub_message_size; + const uint8_t flags = static_cast((frag_idx << 1) | (has_more ? 0x01 : 0x00)); + + std::vector layer3; + if (frag_idx == 0) { + layer3 = bridge_prefix; + } + layer3.insert(layer3.end(), sub_message.begin() + static_cast(offset), sub_message.begin() + static_cast(offset + chunk_size)); + + auto frame = buildCenturionFrame(layer3, flags); + if (auto write_result = this->writeHID(device_handle, frame, frame.size()); !write_result) { + return write_result.error(); + } + + offset += chunk_size; + ++frag_idx; + } + } + + bool ack_received = false; + for (int attempt = 0; attempt < POLL_ATTEMPTS; ++attempt) { + std::array response {}; + auto read_result = this->readHIDTimeout(device_handle, response, hsc_device_timeout); + if (!read_result) { + return read_result.error(); + } + + auto payload_result = extractCenturionPayload(response); + if (!payload_result) { + return payload_result.error(); + } + + const auto& reply = *payload_result; + if (reply.size() < 2 || reply[0] != *centurion_bridge_index_) { + continue; + } + + uint8_t func_sw = reply[1]; + if ((func_sw >> 4) != (BRIDGE_MESSAGE_EVENT_FN >> 4)) { + continue; + } + + if ((func_sw & 0x0F) == SOFTWARE_ID) { + ack_received = true; + continue; + } + + if ((func_sw & 0x0F) != 0x00) { + continue; + } + + if (!isBridgeResponseFor(reply, sub_feature_index)) { + continue; + } + + return parseBridgeResponse(reply); + } + + if (!ack_received) { + return DeviceError::timeout("Timed out waiting for Centurion bridge acknowledgment"); + } + + return DeviceError::timeout("Timed out waiting for Centurion bridge response"); + } + + static constexpr auto buildDirectPayload(uint8_t feature_index, uint8_t function, std::span params) + -> std::array + { + std::array payload {}; + payload[0] = feature_index; + payload[1] = static_cast((function & 0xF0) | SOFTWARE_ID); + + for (size_t i = 0; i < params.size() && (i + 2) < payload.size(); ++i) { + payload[i + 2] = params[i]; + } + + return payload; + } + + static Result> extractCenturionPayload(std::span frame) + { + if (frame.size() < 4 || frame[0] != REPORT_ID) { + return DeviceError::protocolError("Unexpected Centurion frame prefix"); + } + + size_t cpl_length = frame[1]; + if (cpl_length <= 1 || (cpl_length + 2) > frame.size()) { + return DeviceError::protocolError("Invalid Centurion frame length"); + } + + return std::vector(frame.begin() + 3, frame.begin() + 2 + cpl_length); + } + + mutable bool centurion_features_discovered_ = false; + mutable std::optional centurion_bridge_index_; + mutable std::unordered_map centurion_sub_features_; +}; + +} // namespace headsetcontrol::protocols \ No newline at end of file diff --git a/lib/headsetcontrol.cpp b/lib/headsetcontrol.cpp index 5722949..b83ee2b 100644 --- a/lib/headsetcontrol.cpp +++ b/lib/headsetcontrol.cpp @@ -296,6 +296,11 @@ uint8_t Headset::getEqualizerPresetsCount() const return impl_->device()->getEqualizerPresetsCount(); } +std::optional Headset::getEqualizerPresets() const +{ + return impl_->device()->getEqualizerPresets(); +} + Result Headset::setMicVolume(uint8_t volume) { HEADSET_FEATURE_IMPL(CAP_MICROPHONE_VOLUME, setMicVolume, volume); diff --git a/lib/headsetcontrol.hpp b/lib/headsetcontrol.hpp index 2577d59..7e6449d 100644 --- a/lib/headsetcontrol.hpp +++ b/lib/headsetcontrol.hpp @@ -184,6 +184,11 @@ class Headset { */ [[nodiscard]] uint8_t getEqualizerPresetsCount() const; + /** + * @brief Get equalizer preset definitions + */ + [[nodiscard]] std::optional getEqualizerPresets() const; + // ======================================================================== // Microphone // ======================================================================== diff --git a/lib/headsetcontrol_c.cpp b/lib/headsetcontrol_c.cpp index c6574ef..d1dca3c 100644 --- a/lib/headsetcontrol_c.cpp +++ b/lib/headsetcontrol_c.cpp @@ -17,6 +17,7 @@ struct HeadsetWrapper { std::string name_str; // Persistent storage for C string std::string vendor_name_str; std::string product_name_str; + std::optional equalizer_presets_cache; explicit HeadsetWrapper(headsetcontrol::Headset&& h) : headset(std::move(h)) @@ -69,6 +70,19 @@ hsc_result_t toErrorCode(const headsetcontrol::DeviceError& error) // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::string g_version_str; +const EqualizerPresets* getCachedEqualizerPresets(HeadsetWrapper& wrapper) +{ + if (!wrapper.equalizer_presets_cache.has_value()) { + wrapper.equalizer_presets_cache = wrapper.headset.getEqualizerPresets(); + } + + if (!wrapper.equalizer_presets_cache.has_value()) { + return nullptr; + } + + return &(*wrapper.equalizer_presets_cache); +} + } // namespace // ============================================================================ @@ -326,6 +340,65 @@ int hsc_get_equalizer_presets_count(hsc_headset_t headset) return static_cast(headset)->headset.getEqualizerPresetsCount(); } +const char* hsc_get_equalizer_preset_name(hsc_headset_t headset, int preset) +{ + if (!headset || preset < 0) { + return nullptr; + } + + auto& wrapper = *static_cast(headset); + const auto* presets = getCachedEqualizerPresets(wrapper); + if (!presets || preset >= static_cast(presets->presets.size())) { + return nullptr; + } + + return presets->presets[static_cast(preset)].name.c_str(); +} + +int hsc_get_equalizer_preset_band_count(hsc_headset_t headset, int preset) +{ + if (!headset || preset < 0) { + return 0; + } + + auto& wrapper = *static_cast(headset); + const auto* presets = getCachedEqualizerPresets(wrapper); + if (!presets || preset >= static_cast(presets->presets.size())) { + return 0; + } + + return static_cast(presets->presets[static_cast(preset)].values.size()); +} + +hsc_result_t hsc_get_equalizer_preset_bands( + hsc_headset_t headset, + int preset, + float* bands, + int num_bands) +{ + if (!headset || !bands || preset < 0 || num_bands <= 0) { + return HSC_RESULT_INVALID_PARAM; + } + + auto& wrapper = *static_cast(headset); + const auto* presets = getCachedEqualizerPresets(wrapper); + if (!presets) { + return HSC_RESULT_NOT_SUPPORTED; + } + + if (preset >= static_cast(presets->presets.size())) { + return HSC_RESULT_INVALID_PARAM; + } + + const auto& values = presets->presets[static_cast(preset)].values; + if (num_bands < static_cast(values.size())) { + return HSC_RESULT_INVALID_PARAM; + } + + std::memcpy(bands, values.data(), values.size() * sizeof(float)); + return HSC_RESULT_OK; +} + // ============================================================================ // Microphone // ============================================================================ diff --git a/lib/headsetcontrol_c.h b/lib/headsetcontrol_c.h index 2722f27..7877d26 100644 --- a/lib/headsetcontrol_c.h +++ b/lib/headsetcontrol_c.h @@ -325,6 +325,39 @@ HSC_API hsc_result_t hsc_set_equalizer(hsc_headset_t headset, const float* bands */ HSC_API int hsc_get_equalizer_presets_count(hsc_headset_t headset); +/** + * @brief Get equalizer preset name + * + * @param headset Headset handle + * @param preset Preset index + * @return Preset name (do not free), or NULL if unavailable + */ +HSC_API const char* hsc_get_equalizer_preset_name(hsc_headset_t headset, int preset); + +/** + * @brief Get equalizer preset band count + * + * @param headset Headset handle + * @param preset Preset index + * @return Number of bands in the preset, or 0 if unavailable + */ +HSC_API int hsc_get_equalizer_preset_band_count(hsc_headset_t headset, int preset); + +/** + * @brief Copy equalizer preset bands into caller-provided buffer + * + * @param headset Headset handle + * @param preset Preset index + * @param[out] bands Buffer receiving band values + * @param num_bands Number of elements available in bands + * @return HSC_RESULT_OK on success, negative error code on failure + */ +HSC_API hsc_result_t hsc_get_equalizer_preset_bands( + hsc_headset_t headset, + int preset, + float* bands, + int num_bands); + /* ============================================================================ * Microphone * ============================================================================ */ diff --git a/tests/test_device_registry.cpp b/tests/test_device_registry.cpp index 8bbf594..058d77b 100644 --- a/tests/test_device_registry.cpp +++ b/tests/test_device_registry.cpp @@ -209,6 +209,10 @@ void testLookupLogitechProX2Lightspeed() 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"); + ASSERT_TRUE((device->getCapabilities() & B(CAP_SIDETONE)) != 0, "G PRO X2 should expose sidetone capability"); + ASSERT_TRUE((device->getCapabilities() & B(CAP_EQUALIZER_PRESET)) != 0, "G PRO X2 should expose equalizer preset capability"); + ASSERT_TRUE((device->getCapabilities() & B(CAP_EQUALIZER)) != 0, "G PRO X2 should expose equalizer capability"); + ASSERT_TRUE((device->getCapabilities() & B(CAP_PARAMETRIC_EQUALIZER)) != 0, "G PRO X2 should expose parametric equalizer capability"); std::cout << " OK lookup Logitech PRO X2 LIGHTSPEED" << std::endl; } diff --git a/tests/test_library_api.cpp b/tests/test_library_api.cpp index ffeb927..ee91f1e 100644 --- a/tests/test_library_api.cpp +++ b/tests/test_library_api.cpp @@ -15,6 +15,7 @@ #include "headsetcontrol_c.h" #include +#include #include #include #include @@ -331,6 +332,15 @@ void testCppTestDeviceMode() // Test equalizer presets count ASSERT_EQ(4, headset.getEqualizerPresetsCount(), "Should have 4 presets"); + auto presets = headset.getEqualizerPresets(); + ASSERT_TRUE(presets.has_value(), "Equalizer presets should be available"); + ASSERT_EQ(4, presets->count(), "Should return 4 equalizer presets"); + ASSERT_EQ(std::string("Flat"), presets->presets[0].name, "First preset name should match"); + ASSERT_EQ(10u, presets->presets[0].values.size(), "Flat preset should have 10 bands"); + ASSERT_EQ(0.0f, presets->presets[0].values[0], "Flat preset first band should be 0"); + ASSERT_EQ(std::string("Bass Boost"), presets->presets[1].name, "Second preset name should match"); + ASSERT_EQ(6.0f, presets->presets[1].values[0], "Bass Boost first band should match"); + // Test equalizer preset ASSERT_TRUE(headset.supports(CAP_EQUALIZER_PRESET), "Test device should support equalizer preset"); auto eq_preset = headset.setEqualizerPreset(2); @@ -421,6 +431,17 @@ void testCTestDeviceMode() ASSERT_EQ(HSC_RESULT_OK, hsc_set_sidetone(headsets[i], 64, &sidetone), "Sidetone should succeed"); ASSERT_EQ(64, sidetone.current_level, "Sidetone level should be 64"); + ASSERT_EQ(4, hsc_get_equalizer_presets_count(headsets[i]), "Preset count should be 4"); + ASSERT_EQ(std::string("Flat"), std::string(hsc_get_equalizer_preset_name(headsets[i], 0)), "Flat preset name should match"); + ASSERT_EQ(10, hsc_get_equalizer_preset_band_count(headsets[i], 0), "Flat preset should have 10 bands"); + + std::array preset_bands {}; + ASSERT_EQ(HSC_RESULT_OK, + hsc_get_equalizer_preset_bands(headsets[i], 1, preset_bands.data(), static_cast(preset_bands.size())), + "Fetching preset bands should succeed"); + ASSERT_EQ(6.0f, preset_bands[0], "Bass Boost first band should match"); + ASSERT_EQ(4.0f, preset_bands[1], "Bass Boost second band should match"); + break; } } @@ -549,6 +570,67 @@ void testVendorProductNames() std::cout << " OK vendor/product name accessors" << std::endl; } +void testEqualizerPresetConsistency() +{ + std::cout << " Testing equalizer preset consistency between C++ and C APIs..." << std::endl; + + headsetcontrol::enableTestDevice(true); + + auto cpp_headsets = headsetcontrol::discover(); + hsc_headset_t* c_headsets = nullptr; + int c_count = hsc_discover(&c_headsets); + + Headset* cpp_test_device = nullptr; + for (auto& headset : cpp_headsets) { + if (headset.vendorId() == 0xF00B && headset.productId() == 0xA00C) { + cpp_test_device = &headset; + break; + } + } + + hsc_headset_t c_test_device = nullptr; + for (int i = 0; i < c_count; i++) { + if (hsc_get_vendor_id(c_headsets[i]) == 0xF00B && hsc_get_product_id(c_headsets[i]) == 0xA00C) { + c_test_device = c_headsets[i]; + break; + } + } + + ASSERT_TRUE(cpp_test_device != nullptr, "Should find C++ test device"); + ASSERT_TRUE(c_test_device != nullptr, "Should find C test device"); + + auto cpp_presets = cpp_test_device->getEqualizerPresets(); + ASSERT_TRUE(cpp_presets.has_value(), "C++ equalizer presets should be available"); + ASSERT_EQ(cpp_test_device->getEqualizerPresetsCount(), hsc_get_equalizer_presets_count(c_test_device), + "Preset counts should match"); + + for (int preset_index = 0; preset_index < cpp_presets->count(); preset_index++) { + const auto& cpp_preset = cpp_presets->presets[static_cast(preset_index)]; + ASSERT_EQ(cpp_preset.name, std::string(hsc_get_equalizer_preset_name(c_test_device, preset_index)), + "Preset names should match"); + ASSERT_EQ(static_cast(cpp_preset.values.size()), hsc_get_equalizer_preset_band_count(c_test_device, preset_index), + "Preset band counts should match"); + + std::vector c_values(cpp_preset.values.size()); + ASSERT_EQ(HSC_RESULT_OK, + hsc_get_equalizer_preset_bands(c_test_device, preset_index, c_values.data(), static_cast(c_values.size())), + "Preset bands fetch should succeed"); + ASSERT_TRUE(c_values == cpp_preset.values, "Preset bands should match"); + } + + ASSERT_TRUE(hsc_get_equalizer_preset_name(c_test_device, -1) == nullptr, "Negative preset index should return null"); + ASSERT_EQ(0, hsc_get_equalizer_preset_band_count(c_test_device, 999), "Out-of-range preset band count should be 0"); + std::array short_buffer {}; + ASSERT_EQ(HSC_RESULT_INVALID_PARAM, + hsc_get_equalizer_preset_bands(c_test_device, 0, short_buffer.data(), static_cast(short_buffer.size())), + "Too-small preset band buffer should fail"); + + hsc_free_headsets(c_headsets, c_count); + headsetcontrol::enableTestDevice(false); + + std::cout << " OK equalizer preset consistency" << std::endl; +} + // ============================================================================ // Test Runner // ============================================================================ @@ -594,6 +676,7 @@ void runAllLibraryApiTests() runTest("Device count consistency", testDeviceCountConsistency); runTest("Device names consistency", testDeviceNamesConsistency); runTest("Vendor/product names", testVendorProductNames); + runTest("Equalizer preset consistency", testEqualizerPresetConsistency); std::cout << "\n=== Test Device Mode Tests ===" << std::endl; runTest("C++ test device mode", testCppTestDeviceMode); diff --git a/tests/test_protocols.cpp b/tests/test_protocols.cpp index 1f80a25..7ec6e85 100644 --- a/tests/test_protocols.cpp +++ b/tests/test_protocols.cpp @@ -12,6 +12,7 @@ #include "device.hpp" #include "devices/corsair_device.hpp" #include "devices/logitech_gpro_x2_lightspeed.hpp" +#include "devices/protocols/logitech_centurion_protocol.hpp" #include "devices/protocols/hidpp_protocol.hpp" #include "devices/protocols/logitech_calibrations.hpp" #include "devices/protocols/steelseries_protocol.hpp" @@ -335,6 +336,142 @@ void testLogitechProX2BatteryOutOfRange() std::cout << " [OK] Logitech PRO X2 battery out-of-range rejection verified" << std::endl; } +void testLogitechProX2CenturionBatteryParsing() +{ + std::cout << " Testing Logitech PRO X2 Centurion battery parsing..." << std::endl; + + std::array charging_v1 { 42, 42, 0x01 }; + auto charging_v1_result = LogitechGProX2Lightspeed::parseCenturionBatteryResponse(charging_v1); + ASSERT_TRUE(charging_v1_result.hasValue(), "Centurion charging state 0x01 should parse successfully"); + ASSERT_EQ(BATTERY_CHARGING, charging_v1_result->status, "Centurion charging state 0x01 should map to charging"); + + std::array charging { 87, 87, 0x02 }; + auto charging_result = LogitechGProX2Lightspeed::parseCenturionBatteryResponse(charging); + ASSERT_TRUE(charging_result.hasValue(), "Centurion charging response should parse successfully"); + ASSERT_EQ(87, charging_result->level_percent, "Centurion battery percentage should use byte 0"); + ASSERT_EQ(BATTERY_CHARGING, charging_result->status, "Centurion charging state 0x02 should map to charging"); + + std::array full { 100, 100, 0x03 }; + auto full_result = LogitechGProX2Lightspeed::parseCenturionBatteryResponse(full); + ASSERT_TRUE(full_result.hasValue(), "Centurion full response should parse successfully"); + ASSERT_EQ(BATTERY_AVAILABLE, full_result->status, "Centurion full state should map to available"); + + std::array invalid { 101 }; + auto invalid_result = LogitechGProX2Lightspeed::parseCenturionBatteryResponse(invalid); + ASSERT_TRUE(!invalid_result.hasValue(), "Centurion battery level above 100 should be rejected"); + + std::cout << " [OK] Logitech PRO X2 Centurion battery parsing verified" << std::endl; +} + +void testCenturionFrameBuilding() +{ + std::cout << " Testing Logitech Centurion frame building..." << std::endl; + + std::array payload { 0x02, 0x11, 0x99 }; + auto frame = protocols::LogitechCenturionProtocol::buildCenturionFrame(payload); + + ASSERT_EQ(0x51, frame[0], "Centurion frame should start with report ID 0x51"); + ASSERT_EQ(4, frame[1], "CPL length should include the flags byte"); + ASSERT_EQ(0, frame[2], "Single-frame requests should have flags 0"); + ASSERT_EQ(0x02, frame[3], "Payload should start at byte 3"); + ASSERT_EQ(0x99, frame[5], "Payload bytes should be copied unchanged"); + + std::cout << " [OK] Logitech Centurion frame building verified" << std::endl; +} + +void testCenturionBridgeResponseParsing() +{ + std::cout << " Testing Logitech Centurion bridge response parsing..." << std::endl; + + std::array reply { 0x07, 0x10, 0x00, 0x05, 0x00, 0x04, 0x20, 0x2A, 0xF8 }; + ASSERT_TRUE(protocols::LogitechCenturionProtocol::isBridgeResponseFor(reply, 0x04), "Bridge event should match the requested sub-feature index"); + + auto parsed = protocols::LogitechCenturionProtocol::parseBridgeResponse(reply); + ASSERT_TRUE(parsed.hasValue(), "Valid bridge response should parse successfully"); + ASSERT_EQ(2, static_cast(parsed->size()), "Bridge response should return only sub-device payload bytes"); + ASSERT_EQ(0x2A, (*parsed)[0], "Parsed payload should preserve the first response byte"); + ASSERT_EQ(0xF8, (*parsed)[1], "Parsed payload should preserve the second response byte"); + + std::array error_reply { 0x07, 0x10, 0x00, 0x05, 0x00, 0xFF, 0x04, 0x20, 0x01 }; + ASSERT_TRUE(protocols::LogitechCenturionProtocol::isBridgeResponseFor(error_reply, 0x04), "Error bridge response should still match the requested sub-feature index"); + auto error_parse = protocols::LogitechCenturionProtocol::parseBridgeResponse(error_reply); + ASSERT_TRUE(!error_parse.hasValue(), "Sub-device error responses should fail parsing"); + + std::cout << " [OK] Logitech Centurion bridge response parsing verified" << std::endl; +} + +void testCenturionBridgeMessageSizeLimit() +{ + std::cout << " Testing Logitech Centurion bridge message size limit..." << std::endl; + + std::vector params_at_limit(0x0FFF - 3, 0x5A); + auto message_at_limit = protocols::LogitechCenturionProtocol::buildBridgeSubMessageVector(0x04, 0x10, params_at_limit); + ASSERT_EQ(0x0FFF, static_cast(message_at_limit.size()), "Bridge sub-message should reach the 12-bit size limit"); + ASSERT_TRUE(protocols::LogitechCenturionProtocol::isBridgeSubMessageSizeSupported(message_at_limit.size()), + "Bridge sub-message at the 12-bit size limit should be accepted"); + + std::vector params_over_limit(0x1000 - 3, 0x5A); + auto message_over_limit = protocols::LogitechCenturionProtocol::buildBridgeSubMessageVector(0x04, 0x10, params_over_limit); + ASSERT_EQ(0x1000, static_cast(message_over_limit.size()), "Bridge sub-message should exceed the 12-bit size limit by one byte"); + ASSERT_TRUE(!protocols::LogitechCenturionProtocol::isBridgeSubMessageSizeSupported(message_over_limit.size()), + "Bridge sub-message above the 12-bit size limit should be rejected"); + + std::cout << " [OK] Logitech Centurion bridge message size limit verified" << std::endl; +} + +void testLogitechProX2EqualizerInfoRequiresDescriptor() +{ + std::cout << " Testing Logitech PRO X2 equalizer info cache behavior..." << std::endl; + + LogitechGProX2Lightspeed device; + ASSERT_TRUE(!device.getEqualizerInfo().has_value(), "Equalizer info should be unavailable before the descriptor is read"); + ASSERT_TRUE(!device.getParametricEqualizerInfo().has_value(), "Parametric equalizer info should be unavailable before the descriptor is read"); + + std::cout << " [OK] Logitech PRO X2 equalizer info cache behavior verified" << std::endl; +} + +void testLogitechProX2OnboardEqCoefficientQuantization() +{ + std::cout << " Testing Logitech PRO X2 onboard EQ coefficient quantization..." << std::endl; + + auto words = LogitechGProX2Lightspeed::quantizeOnboardEqCoefficientsForTest({ + 0.0, + -100.0 / 1073741824.0, + 0.0, + 0.0, + 0.0, + }); + + ASSERT_EQ(10, static_cast(words.size()), "Quantized coefficient block should contain 10 words"); + ASSERT_EQ(0xFFFF, static_cast(words[2]), "Negative coefficients should preserve sign extension in the upper word"); + ASSERT_EQ(0xFF00, static_cast(words[3]), "Negative coefficients should still clear the low byte"); + + std::cout << " [OK] Logitech PRO X2 onboard EQ coefficient quantization verified" << std::endl; +} + +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 }, + }); + + 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"); + ASSERT_EQ(5, payload[1], "Onboard EQ payload should encode the band count"); + ASSERT_EQ(0x00, payload[2], "First band frequency high byte should be preserved"); + ASSERT_EQ(80, payload[3], "First band frequency low byte should be preserved"); + ASSERT_EQ(4, payload[4], "First band gain should be preserved"); + ASSERT_EQ(1, payload[5], "First band Q should be preserved"); + + std::cout << " [OK] Logitech PRO X2 onboard EQ payload building verified" << std::endl; +} + // ============================================================================ // SteelSeries Protocol Tests // ============================================================================ @@ -583,6 +720,13 @@ void runAllProtocolTests() runTest("Logitech PRO X2 Battery Parsing", testLogitechProX2BatteryPacketParsing); runTest("Logitech PRO X2 Power Event", testLogitechProX2PowerEventDetection); runTest("Logitech PRO X2 Battery Out-of-Range", testLogitechProX2BatteryOutOfRange); + runTest("Logitech PRO X2 Centurion Battery", testLogitechProX2CenturionBatteryParsing); + runTest("Logitech Centurion Frame Building", testCenturionFrameBuilding); + runTest("Logitech Centurion Bridge Parsing", testCenturionBridgeResponseParsing); + runTest("Logitech Centurion Bridge Size Limit", testCenturionBridgeMessageSizeLimit); + runTest("Logitech PRO X2 Equalizer Info Cache", testLogitechProX2EqualizerInfoRequiresDescriptor); + runTest("Logitech PRO X2 EQ Quantization", testLogitechProX2OnboardEqCoefficientQuantization); + runTest("Logitech PRO X2 Onboard EQ Payload", testLogitechProX2OnboardEqPayloadBuilding); std::cout << "\n=== SteelSeries Protocol ===" << std::endl; runTest("SteelSeries Packet Sizes", testSteelSeriesPacketSizes);