diff --git a/README.md b/README.md index f5bf269d..26f30719 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ sudo udevadm control --reload-rules && sudo udevadm trigger | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Logitech ASTRO A50 Gen 4 | All | x | x | | | | x | | | x | x | x | | x | | | | x | x | | Logitech ASTRO A50 Gen 5 | All | x | x | | x | | x | | | | | x | | | | | | x | | -| Logitech G522 LIGHTSPEED | All | x | x | | | x | | | | | | | x | | | | | | | +| Logitech G522 LIGHTSPEED | All | x | x | | x | x | | x | | x | x | x | x | | | | | | x | | Logitech G533 | All | x | x | | | x | | | | | | | | | | | | | | | Logitech G535 | All | x | x | | | x | | | | | | | | | | | | | | | Logitech G633/G635/G733/G933/G935 | All | x | x | | x | | | | | | | | | | | | | | | diff --git a/lib/devices/logitech_g522_lightspeed.hpp b/lib/devices/logitech_g522_lightspeed.hpp index ef40d1bc..0e1a3d94 100644 --- a/lib/devices/logitech_g522_lightspeed.hpp +++ b/lib/devices/logitech_g522_lightspeed.hpp @@ -2,11 +2,16 @@ #include "../utility.hpp" #include "device.hpp" -#include "hid_device.hpp" +#include "protocols/logitech_centurion_protocol.hpp" #include "result_types.hpp" +#include #include #include +#include #include +#include +#include +#include #include #include @@ -17,16 +22,76 @@ namespace headsetcontrol { /** * @brief Logitech G522 LIGHTSPEED (PID 0x0b18) * - * This variant uses a vendor-specific 64-byte protocol on usage page 0xffa0 - * for battery data instead of HID++. - * - * This implementation is modified from the working LIGHTSPEED implementation for the G PRO X 2 (PID 0x0af7) as the used protocol appears to be the same. + * The G522 speaks the Logitech Centurion protocol on usage page 0xffa0, like the + * G PRO X 2 LIGHTSPEED, but its frames start with 0x50 0x23 instead of 0x51. + * Feature indexes are discovered at runtime through the dongle's bridge. */ -class LogitechG522Lightspeed : public HIDDevice { +class LogitechG522Lightspeed : public protocols::LogitechCenturionProtocol { public: static constexpr std::array SUPPORTED_PRODUCT_IDS { 0x0b18 }; - static constexpr size_t PACKET_SIZE = 64; - static constexpr std::array REPORT_PREFIX { 0x50, 0x23 }; + static constexpr std::array FRAME_PREFIX { 0x50, 0x23 }; + static constexpr uint8_t SIDETONE_DEVICE_MAX = 9; + static constexpr uint8_t SIDETONE_MIC_ID = 0x01; + + // Lighting effect parameters (feature 0x0621, function 3): zone 00 00, effect, effect data. + // "On" is G HUB's default look: two-zone effect 04, zone 1 Logitech blue 00b8fc, + // zone 2 magenta ff00ab, brightness 100. "Off" is the fixed effect 00 with color 000000. + static constexpr std::array LIGHTS_ON_PARAMS { 0x00, 0x00, 0x04, 0x00, 0xb8, 0xfc, 0xff, 0x00, 0xab, 0x64 }; + static constexpr std::array LIGHTS_OFF_PARAMS {}; + + // Playback equalizer (feature 0x020d, function 2): 10 peaking bands, each encoded as + // . G HUB shows the + // ranges below: gain -6..+6 dB in 0.05 dB steps, Q 0.031..7.969. + struct EqBand { + uint16_t frequency = 0; + float gain_db = 0.0f; + float q_factor = 0.0f; + }; + + static constexpr size_t EQ_BANDS = 10; + static constexpr float EQ_GAIN_MIN = -6.0f; + static constexpr float EQ_GAIN_MAX = 6.0f; + static constexpr float EQ_GAIN_STEP = 0.05f; + static constexpr int EQ_GAIN_ZERO_RAW = 120; + static constexpr float EQ_Q_SCALE = 32.0f; + static constexpr float EQ_Q_MIN = 1.0f / EQ_Q_SCALE; + static constexpr float EQ_Q_MAX = 255.0f / EQ_Q_SCALE; + static constexpr float EQ_DEFAULT_Q = 22.0f / EQ_Q_SCALE; // 0.688 in G HUB + static constexpr uint16_t EQ_FREQ_MIN = 20; + static constexpr uint16_t EQ_FREQ_MAX = 20000; + static constexpr size_t EQ_PARAMS_SIZE = 3 + EQ_BANDS * 5; + static constexpr uint8_t EQ_PRESETS_COUNT = 5; + + using EqBands = std::array; + + // G HUB's built-in presets, as G HUB displays them. + static constexpr EqBands EQ_PRESET_DEFAULT { { { 20, 0.0f, EQ_DEFAULT_Q }, { 50, 0.0f, EQ_DEFAULT_Q }, + { 125, 0.0f, EQ_DEFAULT_Q }, { 250, 0.0f, EQ_DEFAULT_Q }, { 500, 0.0f, EQ_DEFAULT_Q }, + { 1000, 0.0f, EQ_DEFAULT_Q }, { 2500, 0.0f, EQ_DEFAULT_Q }, { 5000, 0.0f, EQ_DEFAULT_Q }, + { 10000, 0.0f, EQ_DEFAULT_Q }, { 20000, 0.0f, EQ_DEFAULT_Q } } }; + static constexpr EqBands EQ_PRESET_BASS_BOOST { { { 20, 3.0f, EQ_DEFAULT_Q }, { 50, 2.5f, EQ_DEFAULT_Q }, + { 125, 1.75f, EQ_DEFAULT_Q }, { 250, 1.0f, 1.0f }, { 500, 0.0f, EQ_DEFAULT_Q }, + { 1000, 0.0f, EQ_DEFAULT_Q }, { 2500, 0.0f, EQ_DEFAULT_Q }, { 5000, 0.0f, EQ_DEFAULT_Q }, + { 10000, 0.0f, EQ_DEFAULT_Q }, { 20000, 0.0f, EQ_DEFAULT_Q } } }; + static constexpr EqBands EQ_PRESET_GAMING { { { 20, 0.0f, EQ_DEFAULT_Q }, { 50, 0.0f, EQ_DEFAULT_Q }, + { 250, 0.5f, EQ_DEFAULT_Q }, { 400, 0.5f, EQ_DEFAULT_Q }, { 800, 0.75f, EQ_DEFAULT_Q }, + { 1500, 1.0f, EQ_DEFAULT_Q }, { 2500, 1.25f, EQ_DEFAULT_Q }, { 5000, 1.5f, EQ_DEFAULT_Q }, + { 10000, 1.75f, EQ_DEFAULT_Q }, { 19000, 2.75f, EQ_DEFAULT_Q } } }; + static constexpr EqBands EQ_PRESET_GAMING_FPS { { { 20, 0.0f, EQ_DEFAULT_Q }, { 50, 0.0f, EQ_DEFAULT_Q }, + { 125, 0.0f, EQ_DEFAULT_Q }, { 400, -1.0f, EQ_DEFAULT_Q }, { 800, -1.0f, EQ_DEFAULT_Q }, + { 1250, 3.75f, 2.0f }, { 2500, 4.5f, 1.0f }, { 5000, 1.75f, EQ_DEFAULT_Q }, + { 10000, 1.5f, EQ_DEFAULT_Q }, { 19000, 1.5f, EQ_DEFAULT_Q } } }; + static constexpr EqBands EQ_PRESET_MEDIA { { { 20, 4.0f, EQ_DEFAULT_Q }, { 50, 3.0f, EQ_DEFAULT_Q }, + { 125, 2.0f, EQ_DEFAULT_Q }, { 250, -0.5f, EQ_DEFAULT_Q }, { 500, -0.5f, EQ_DEFAULT_Q }, + { 1000, 1.0f, EQ_DEFAULT_Q }, { 2500, 2.0f, EQ_DEFAULT_Q }, { 5000, 1.5f, EQ_DEFAULT_Q }, + { 10000, 0.5f, EQ_DEFAULT_Q }, { 20000, -0.5f, EQ_DEFAULT_Q } } }; + + static constexpr std::array EQ_PRESETS { + &EQ_PRESET_DEFAULT, &EQ_PRESET_BASS_BOOST, &EQ_PRESET_GAMING, &EQ_PRESET_GAMING_FPS, &EQ_PRESET_MEDIA + }; + static constexpr std::array EQ_PRESET_NAMES { + "Default"sv, "Bass Boost"sv, "Gaming"sv, "Gaming - FPS"sv, "Media"sv + }; constexpr uint16_t getVendorId() const override { @@ -45,7 +110,55 @@ class LogitechG522Lightspeed : public HIDDevice { constexpr int getCapabilities() const override { - return B(CAP_SIDETONE) | B(CAP_BATTERY_STATUS) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_MUTE_LED_BRIGHTNESS); + return B(CAP_SIDETONE) | B(CAP_BATTERY_STATUS) | B(CAP_LIGHTS) | B(CAP_INACTIVE_TIME) | B(CAP_VOICE_PROMPTS) + | B(CAP_EQUALIZER_PRESET) | B(CAP_EQUALIZER) | B(CAP_PARAMETRIC_EQUALIZER) + | B(CAP_MICROPHONE_MUTE_LED_BRIGHTNESS) | B(CAP_SIDETONE_STATUS); + } + + uint8_t getEqualizerPresetsCount() const override + { + return EQ_PRESETS_COUNT; + } + + std::optional getEqualizerPresets() const override + { + EqualizerPresets presets; + for (size_t i = 0; i < EQ_PRESETS_COUNT; ++i) { + std::vector gains; + gains.reserve(EQ_BANDS); + for (const auto& band : *EQ_PRESETS[i]) { + gains.push_back(band.gain_db); + } + presets.presets.push_back({ std::string(EQ_PRESET_NAMES[i]), std::move(gains) }); + } + return presets; + } + + std::optional getEqualizerInfo() const override + { + return EqualizerInfo { + .bands_count = static_cast(EQ_BANDS), + .bands_baseline = 0, + .bands_step = EQ_GAIN_STEP, + .bands_min = static_cast(EQ_GAIN_MIN), + .bands_max = static_cast(EQ_GAIN_MAX) + }; + } + + std::optional getParametricEqualizerInfo() const override + { + return ParametricEqualizerInfo { + .bands_count = static_cast(EQ_BANDS), + .gain_base = 0.0f, + .gain_step = EQ_GAIN_STEP, + .gain_min = EQ_GAIN_MIN, + .gain_max = EQ_GAIN_MAX, + .q_factor_min = EQ_Q_MIN, + .q_factor_max = EQ_Q_MAX, + .freq_min = EQ_FREQ_MIN, + .freq_max = EQ_FREQ_MAX, + .filter_types = B(static_cast(EqualizerFilterType::Peaking)) + }; } constexpr capability_detail getCapabilityDetail(enum capabilities cap) const override @@ -53,7 +166,13 @@ class LogitechG522Lightspeed : public HIDDevice { switch (cap) { case CAP_BATTERY_STATUS: case CAP_SIDETONE: + case CAP_SIDETONE_STATUS: + case CAP_LIGHTS: case CAP_INACTIVE_TIME: + case CAP_VOICE_PROMPTS: + case CAP_EQUALIZER_PRESET: + case CAP_EQUALIZER: + case CAP_PARAMETRIC_EQUALIZER: case CAP_MICROPHONE_MUTE_LED_BRIGHTNESS: return { .usagepage = 0xffa0, .usageid = 0x0001, .interface_id = 3 }; default: @@ -65,51 +184,23 @@ class LogitechG522Lightspeed : public HIDDevice { { 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(); + auto battery = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::CenturionBatterySoc), + 0x00); + if (!battery) { + return battery.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; + auto battery_result = parseCenturionBatteryResponse(*battery); + if (!battery_result) { + return battery_result.error(); } - return DeviceError::protocolError("Battery response packet not received"); + battery_result->raw_data = *battery; + auto end_time = std::chrono::steady_clock::now(); + battery_result->query_duration = std::chrono::duration_cast(end_time - start_time); + return *battery_result; } Result setSidetone(hid_device* device_handle, uint8_t level) override @@ -125,10 +216,14 @@ class LogitechG522Lightspeed : public HIDDevice { // 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) { + uint8_t mapped = map(level, 0, 128, 0, SIDETONE_DEVICE_MAX); + + if (auto write_result = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetAudioSidetone), + 0x10, + std::array { SIDETONE_MIC_ID, 0xFF, mapped }); + !write_result) { return write_result.error(); } @@ -137,14 +232,113 @@ class LogitechG522Lightspeed : public HIDDevice { .min_level = 0, .max_level = 128, .device_min = 0, - .device_max = 9 + .device_max = SIDETONE_DEVICE_MAX }; } + Result getSidetone(hid_device* device_handle) override + { + auto reply = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetAudioSidetone), + 0x00); + if (!reply) { + return reply.error(); + } + return parseSidetoneResponse(*reply); + } + + /** + * @brief Parse the sidetone read reply: . + * + * The headset only stores 10 steps, so the read-back level is approximate: + * setting 64 stores step 4, which reads back as 56. + */ + static Result parseSidetoneResponse(std::span params) + { + if (params.size() < 4 || params[3] > SIDETONE_DEVICE_MAX) { + return DeviceError::protocolError("Unexpected G522 sidetone reply"); + } + + const uint8_t device_level = params[3]; + return SidetoneResult { + .current_level = map(device_level, 0, SIDETONE_DEVICE_MAX, 0, 128), + .min_level = 0, + .max_level = 128, + .device_min = 0, + .device_max = SIDETONE_DEVICE_MAX, + .is_muted = device_level == 0, + .device_level = device_level, + }; + } + + Result setLights(hid_device* device_handle, bool on) override + { + // The headset has no separate on/off switch for its lighting: "off" writes a black + // fixed color, and "on" restores G HUB's default look rather than the previous one. + if (auto write_result = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetLighting), + 0x30, + on ? LIGHTS_ON_PARAMS : LIGHTS_OFF_PARAMS); + !write_result) { + return write_result.error(); + } + + return LightsResult { .enabled = on }; + } + + Result setVoicePrompts(hid_device* device_handle, bool enabled) override + { + // 1 = spoken prompts, 0 = tones (not silence). The difference is heard on the + // Bluetooth/LIGHTSPEED switch ("lightspeed" spoken vs a beep); mic mute always beeps. + if (auto write_result = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetVoicePrompts), + 0x50, + std::array { 0x00, static_cast(enabled) }); + !write_result) { + return write_result.error(); + } + + return VoicePromptsResult { .enabled = enabled }; + } + + /** + * @brief Build the auto-sleep write parameters: . + * + * The same command also carries the two lighting inactivity timers (minutes, 0 = never), + * and the headset rejects a write without them, so keep the values that were read back. + */ + static Result> buildAutoSleepParams(uint8_t minutes, std::span current) + { + if (current.size() < 3) { + return DeviceError::protocolError("Unexpected G522 auto-sleep reply"); + } + return std::array { minutes, current[1], current[2] }; + } + 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) { + auto current = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::CenturionAutoSleep), + 0x00); + if (!current) { + return current.error(); + } + + auto params = buildAutoSleepParams(minutes, *current); + if (!params) { + return params.error(); + } + + if (auto write_result = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::CenturionAutoSleep), + 0x10, + *params); + !write_result) { return write_result.error(); } @@ -158,8 +352,13 @@ class LogitechG522Lightspeed : public HIDDevice { Result setMicMuteLedBrightness(hid_device* device_handle, uint8_t brightness) override { uint8_t mute_led = static_cast(static_cast(brightness)); // 0 or 1 - auto command = buildMicMuteLedCommand(mute_led); - if (auto write_result = writeHID(device_handle, command, PACKET_SIZE); !write_result) { + + if (auto write_result = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetMicMuteLed), + 0x20, + std::array { mute_led }); + !write_result) { return write_result.error(); } @@ -170,113 +369,145 @@ class LogitechG522Lightspeed : public HIDDevice { }; } - static constexpr bool isAckPacket(std::span packet) + Result setEqualizerPreset(hid_device* device_handle, uint8_t preset) override { - return packet.size() >= 3 && packet[0] == REPORT_PREFIX[0] && packet[1] == REPORT_PREFIX[1] && packet[2] == 0x03; - } + if (preset >= EQ_PRESETS_COUNT) { + return DeviceError::invalidParameter("Device only supports presets 0-4"); + } - static constexpr bool isPowerOffPacket(std::span packet) - { - return packet.size() >= 7 && packet[0] == REPORT_PREFIX[0] && packet[1] == REPORT_PREFIX[1] && packet[2] == 0x05 && packet[7] == 0x00; - } + if (auto write_result = writeEqualizer(device_handle, *EQ_PRESETS[preset]); !write_result) { + return write_result.error(); + } - static constexpr bool isPowerEventPacket(std::span packet) - { - return packet.size() >= 3 && packet[0] == REPORT_PREFIX[0] && packet[1] == REPORT_PREFIX[1] && packet[2] == 0x05; + return EqualizerPresetResult { + .preset = preset, + .total_presets = EQ_PRESETS_COUNT + }; } - static constexpr bool isBatteryResponsePacket(std::span packet) + Result setEqualizer(hid_device* device_handle, const EqualizerSettings& settings) override { - return packet.size() >= 14 && packet[0] == REPORT_PREFIX[0] && packet[1] == REPORT_PREFIX[1] && packet[2] == 0x0b && packet[9] == 0x05; + if (settings.size() != static_cast(EQ_BANDS)) { + return DeviceError::invalidParameter("Equalizer requires 10 gain values"); + } + + // Custom gains use the Default preset's frequencies and Q. + EqBands bands = EQ_PRESET_DEFAULT; + for (size_t i = 0; i < EQ_BANDS; ++i) { + bands[i].gain_db = settings.bands[i]; + if (auto error = validateEqBand(bands[i])) { + return *error; + } + } + + if (auto write_result = writeEqualizer(device_handle, bands); !write_result) { + return write_result.error(); + } + + return EqualizerResult {}; } - static Result parseBatteryResponse(std::span packet) + Result setParametricEqualizer( + hid_device* device_handle, + const ParametricEqualizerSettings& settings) override { - if (!isBatteryResponsePacket(packet)) { - return DeviceError::protocolError("Unexpected battery response packet"); + if (settings.size() > static_cast(EQ_BANDS)) { + return DeviceError::invalidParameter("Device only supports up to 10 equalizer bands"); } - int level = static_cast(packet[11]); - if (level > 100) { - return DeviceError::protocolError("Battery percentage out of range"); + // Bands that are not given keep the Default preset, so "reset" restores it. + EqBands bands = EQ_PRESET_DEFAULT; + for (size_t i = 0; i < settings.bands.size(); ++i) { + const auto& band = settings.bands[i]; + if (band.type != EqualizerFilterType::Peaking) { + return DeviceError::invalidParameter("This headset only supports peaking EQ bands"); + } + if (!std::isfinite(band.frequency) || band.frequency < EQ_FREQ_MIN || band.frequency > EQ_FREQ_MAX) { + return DeviceError::invalidParameter("Frequency must be between 20 Hz and 20000 Hz"); + } + + bands[i] = EqBand { + .frequency = static_cast(std::lround(band.frequency)), + .gain_db = band.gain, + .q_factor = band.q_factor, + }; + if (auto error = validateEqBand(bands[i])) { + return *error; + } } - auto status = packet[13] == 0x02 ? BATTERY_CHARGING : BATTERY_AVAILABLE; + if (auto write_result = writeEqualizer(device_handle, bands); !write_result) { + return write_result.error(); + } - BatteryResult result { - .level_percent = level, - .status = status, - }; + return ParametricEqualizerResult {}; + } - return result; + /** + * @brief Encode the equalizer parameters: 00 00 00, then 10 bands of 5 bytes. + * + * Gains are rounded to the headset's 0.05 dB steps and Q to steps of 1/32; + * out-of-range values are clamped. + */ + static std::vector buildEqParams(std::span bands) + { + std::vector params { 0x00, 0x00, 0x00 }; + params.reserve(EQ_PARAMS_SIZE); + for (const auto& band : bands) { + const auto q_raw = std::clamp(std::lround(band.q_factor * EQ_Q_SCALE), 1, 255); + const auto gain_raw = std::clamp(std::lround(band.gain_db / EQ_GAIN_STEP) + EQ_GAIN_ZERO_RAW, + 0, 2 * EQ_GAIN_ZERO_RAW); + params.push_back(static_cast(band.frequency >> 8)); + params.push_back(static_cast(band.frequency & 0xFF)); + params.push_back(static_cast(q_raw)); + params.push_back(static_cast(gain_raw >> 8)); + params.push_back(static_cast(gain_raw & 0xFF)); + } + return params; } -private: - static constexpr std::array buildBatteryRequest() +protected: + std::span centurionFramePrefix() const override { - std::array request { }; - request[0] = REPORT_PREFIX[0]; - request[1] = REPORT_PREFIX[1]; - request[2] = 0x0b; - request[4] = 0x03; - request[5] = 0x1a; - request[7] = 0x03; - request[9] = 0x05; - request[10] = 0x0a; - return request; + return FRAME_PREFIX; } - static constexpr std::array buildSidetoneCommand(uint8_t level) + protocols::CenturionOptions centurionOptions() const override { - std::array command { }; - command[0] = REPORT_PREFIX[0]; - command[1] = REPORT_PREFIX[1]; - command[2] = 0x0b; - command[4] = 0x03; - command[5] = 0x1c; - command[7] = 0x06; - command[9] = 0x0d; - command[10] = 0x1c; - command[11] = 0x01; - command[12] = 0xff; - command[13] = level; - return command; + return { + .exact_direct_length = true, + .detect_connection_events = true, + .probe_offline_on_timeout = true, + .lookup_features_by_id = true, + }; } - static constexpr std::array buildInactiveTimeCommand(uint8_t minutes) +private: + static std::optional validateEqBand(const EqBand& band) { - std::array command { }; - command[0] = REPORT_PREFIX[0]; - command[1] = REPORT_PREFIX[1]; - command[2] = 0x0b; - command[4] = 0x03; - command[5] = 0x1c; - command[7] = 0x06; - command[9] = 0x14; - command[10] = 0x1c; - command[11] = minutes; - - // WARN: This has a side effect since there are multiple timers being set with the same command. - // command[12] = 0x00; // This byte sets the time until "lighting goes into inactive mode" e.g. dimmer lights, etc. (can be set in G HUB). - // command[13] = 0x00; // This byte sets the time until "lighting off because of inactivity" - // For both timers, a value of 0 is labeled "never" in G HUB - - return command; + if (!std::isfinite(band.gain_db) || band.gain_db < EQ_GAIN_MIN || band.gain_db > EQ_GAIN_MAX) { + return DeviceError::invalidParameter("Gain must be between -6 and +6 dB"); + } + // Check Q after rounding to the headset's 1/32 steps, so G HUB's displayed limits + // (0.031 and 7.969) are accepted. + if (!std::isfinite(band.q_factor) || std::lround(band.q_factor * EQ_Q_SCALE) < 1 + || std::lround(band.q_factor * EQ_Q_SCALE) > 255) { + return DeviceError::invalidParameter("Q factor must be between 0.031 and 7.969"); + } + return std::nullopt; } - static constexpr std::array buildMicMuteLedCommand(uint8_t mute_led) + Result writeEqualizer(hid_device* device_handle, const EqBands& bands) const { - std::array command { }; - command[0] = REPORT_PREFIX[0]; - command[1] = REPORT_PREFIX[1]; - command[2] = 0x09; - command[4] = 0x03; - command[5] = 0x1c; - command[7] = 0x04; - command[9] = 0x15; - command[10] = 0x2c; - command[11] = mute_led; - return command; + auto write_result = sendCenturionFeatureRequest( + device_handle, + static_cast(protocols::CenturionFeature::HeadsetAdvancedParaEQ), + 0x20, + buildEqParams(bands)); + if (!write_result) { + return write_result.error(); + } + return {}; } }; diff --git a/lib/devices/logitech_gpro_x2_lightspeed.hpp b/lib/devices/logitech_gpro_x2_lightspeed.hpp index 37774e4a..ab894d0b 100644 --- a/lib/devices/logitech_gpro_x2_lightspeed.hpp +++ b/lib/devices/logitech_gpro_x2_lightspeed.hpp @@ -22,12 +22,12 @@ namespace headsetcontrol { 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; + 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 { @@ -122,7 +122,7 @@ class LogitechGProX2Lightspeed : public protocols::LogitechCenturionProtocol { Result getBattery(hid_device* device_handle) override { auto centurion_start_time = std::chrono::steady_clock::now(); - auto centurion_battery = sendCenturionFeatureRequest( + auto centurion_battery = sendCenturionFeatureRequest( device_handle, static_cast(protocols::CenturionFeature::CenturionBatterySoc), 0x00); @@ -209,8 +209,7 @@ class LogitechGProX2Lightspeed : public protocols::LogitechCenturionProtocol { bands.push_back(AdvancedEqBand { .frequency = descriptor->bands[i].frequency, - .gain_db = encodeGain(gain) - }); + .gain_db = encodeGain(gain) }); } if (auto write_result = writePlaybackAdvancedEq(device_handle, *descriptor, bands); !write_result) { @@ -284,8 +283,7 @@ class LogitechGProX2Lightspeed : public protocols::LogitechCenturionProtocol { 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)) - }); + .q_factor = static_cast(std::clamp(std::lround(band.q_factor), 1, 255)) }); } if (auto write_result = writePlaybackAdvancedEq(device_handle, *descriptor, bands); !write_result) { @@ -313,29 +311,6 @@ class LogitechGProX2Lightspeed : public protocols::LogitechCenturionProtocol { }; } - 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, @@ -452,9 +427,9 @@ class LogitechGProX2Lightspeed : public protocols::LogitechCenturionProtocol { struct AdvancedEqDescriptor { EqualizerBackend backend = EqualizerBackend::AdvancedParametric; - uint8_t active_slot = 0; - float gain_min = -12.0f; - float gain_max = 12.0f; + uint8_t active_slot = 0; + float gain_min = -12.0f; + float gain_max = 12.0f; std::vector bands; }; @@ -469,8 +444,7 @@ class LogitechGProX2Lightspeed : public protocols::LogitechCenturionProtocol { converted.push_back(AdvancedEqBand { .frequency = frequency, .gain_db = gain_db, - .q_factor = q_factor - }); + .q_factor = q_factor }); } return buildOnboardEqPayload(slot, converted); } @@ -481,12 +455,11 @@ class LogitechGProX2Lightspeed : public protocols::LogitechCenturionProtocol { } private: - void cacheEqualizerInfo(const AdvancedEqDescriptor& descriptor) const { - cached_band_count_ = static_cast(descriptor.bands.size()); - cached_gain_min_ = static_cast(descriptor.gain_min); - cached_gain_max_ = static_cast(descriptor.gain_max); + 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; } @@ -508,11 +481,11 @@ class LogitechGProX2Lightspeed : public protocols::LogitechCenturionProtocol { 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; + 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, @@ -685,8 +658,7 @@ class LogitechGProX2Lightspeed : public protocols::LogitechCenturionProtocol { descriptor.bands.push_back(AdvancedEqBand { .frequency = frequency, - .gain_db = decodeSignedByte((*params_reply)[offset + 2]) - }); + .gain_db = decodeSignedByte((*params_reply)[offset + 2]) }); } if (descriptor.bands.empty()) { @@ -737,8 +709,7 @@ class LogitechGProX2Lightspeed : public protocols::LogitechCenturionProtocol { 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] - }); + .q_factor = (*params_reply)[offset + 3] }); offset += 4; } diff --git a/lib/devices/protocols/logitech_centurion_protocol.hpp b/lib/devices/protocols/logitech_centurion_protocol.hpp index 126c5dd8..1661c074 100644 --- a/lib/devices/protocols/logitech_centurion_protocol.hpp +++ b/lib/devices/protocols/logitech_centurion_protocol.hpp @@ -3,8 +3,10 @@ #include "../../device.hpp" #include "../../result_types.hpp" #include "../hid_device.hpp" +#include #include #include +#include #include #include #include @@ -18,6 +20,9 @@ enum class CenturionFeature : uint16_t { CenturionBatterySoc = 0x0104, CenturionAutoSleep = 0x0108, HeadsetAdvancedParaEQ = 0x020d, + HeadsetMicMuteLed = 0x0601, + HeadsetVoicePrompts = 0x060b, + HeadsetLighting = 0x0621, HeadsetOnboardEQ = 0x0636, HeadsetAudioSidetone = 0x0604, }; @@ -28,31 +33,82 @@ struct CenturionFeatureInfo { uint8_t flags = 0; }; +/** + * @brief Opt-in protocol behaviors, all off by default. + * + * Devices that have not been verified with a behavior keep the defaults, so + * their traffic stays exactly as before the behavior was introduced. + */ +struct CenturionOptions { + // Set the length byte of direct (non-bridged) requests to the real payload + // size instead of the full 64-byte payload buffer. + bool exact_direct_length = false; + // Report a bridge "headset disconnected" event as DeviceError::deviceOffline. + bool detect_connection_events = false; + // After a bridge ACK followed by a read timeout, query the bridge's + // connection status once and report deviceOffline if the headset is gone. + bool probe_offline_on_timeout = false; + // Look up each feature index with Root.getFeature when first needed, + // instead of enumerating every feature of the dongle and the headset. + bool lookup_features_by_id = false; +}; + 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 uint8_t REPORT_ID = 0x51; + static constexpr std::array DEFAULT_FRAME_PREFIX { REPORT_ID }; + 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; + /** + * @brief Bytes that start every frame, before the length byte. + * + * The G PRO X 2 uses report ID 0x51; other Centurion devices use a longer + * prefix (the G522 uses 0x50 0x23). + */ + virtual std::span centurionFramePrefix() const + { + return DEFAULT_FRAME_PREFIX; + } + + virtual CenturionOptions centurionOptions() const + { + return {}; + } + + /** + * @param match_function Only accept a reply that echoes the request's + * function and software ID, not just its feature index. + */ [[nodiscard]] Result> sendCenturionRequest( hid_device* device_handle, uint8_t feature_index, uint8_t function, - std::span params = {}) const + std::span params = {}, + bool match_function = false) const { - auto payload = buildDirectPayload(feature_index, function, params); - auto frame = buildCenturionFrame(payload); + const auto options = centurionOptions(); + const auto prefix = centurionFramePrefix(); + + std::array frame {}; + if (options.exact_direct_length) { + frame = buildCenturionFrame(buildDirectPayloadVector(feature_index, function, params), 0x00, prefix); + } else { + frame = buildCenturionFrame(buildDirectPayload(feature_index, function, params), 0x00, prefix); + } if (auto write_result = this->writeHID(device_handle, frame, frame.size()); !write_result) { return write_result.error(); } + const auto function_sw = static_cast((function & 0xF0) | SOFTWARE_ID); + for (int attempt = 0; attempt < POLL_ATTEMPTS; ++attempt) { std::array response {}; auto read_result = this->readHIDTimeout(device_handle, response, hsc_device_timeout); @@ -60,7 +116,7 @@ class LogitechCenturionProtocol : public HIDDevice { return read_result.error(); } - auto payload_result = extractCenturionPayload(response); + auto payload_result = extractCenturionPayload(response, prefix); if (!payload_result) { return payload_result.error(); } @@ -70,10 +126,22 @@ class LogitechCenturionProtocol : public HIDDevice { continue; } + if (options.detect_connection_events && centurion_bridge_index_.has_value() + && isBridgeConnectionEvent(reply, *centurion_bridge_index_)) { + if (isBridgeDisconnectedEvent(reply, *centurion_bridge_index_)) { + return DeviceError::deviceOffline("Headset is powered off or not connected"); + } + continue; + } + if (reply[0] != feature_index) { continue; } + if (match_function && reply[1] != function_sw) { + continue; + } + return std::vector(reply.begin() + 2, reply.end()); } @@ -84,6 +152,10 @@ class LogitechCenturionProtocol : public HIDDevice { hid_device* device_handle, uint16_t feature_id) const { + if (centurionOptions().lookup_features_by_id) { + return lookupCenturionFeature(device_handle, feature_id); + } + auto init_result = ensureCenturionFeaturesDiscovered(device_handle); if (!init_result) { return init_result.error(); @@ -117,21 +189,97 @@ class LogitechCenturionProtocol : public HIDDevice { return sub_message_size <= MAX_BRIDGE_SUB_MESSAGE_SIZE; } - static constexpr auto buildCenturionFrame(std::span payload, uint8_t flags = 0x00) - -> std::array + static constexpr auto buildCenturionFrame( + std::span payload, + uint8_t flags = 0x00, + std::span prefix = DEFAULT_FRAME_PREFIX) -> std::array { std::array frame {}; - frame[0] = REPORT_ID; - frame[1] = static_cast(payload.size() + 1); - frame[2] = flags; + const size_t header = prefix.size(); + for (size_t i = 0; i < header; ++i) { + frame[i] = prefix[i]; + } + frame[header] = static_cast(payload.size() + 1); + frame[header + 1] = flags; - for (size_t i = 0; i < payload.size() && (i + 3) < frame.size(); ++i) { - frame[i + 3] = payload[i]; + for (size_t i = 0; i < payload.size() && (i + header + 2) < frame.size(); ++i) { + frame[i + header + 2] = payload[i]; } return frame; } + /** + * @brief Return the payload of a frame (the bytes after the length and flags bytes). + */ + static Result> extractCenturionPayload( + std::span frame, + std::span prefix = DEFAULT_FRAME_PREFIX) + { + const size_t header = prefix.size(); + if (frame.size() < header + 3 || !std::equal(prefix.begin(), prefix.end(), frame.begin())) { + return DeviceError::protocolError("Unexpected Centurion frame prefix"); + } + + size_t cpl_length = frame[header]; + if (cpl_length <= 1 || (cpl_length + header + 1) > frame.size()) { + return DeviceError::protocolError("Invalid Centurion frame length"); + } + + return std::vector(frame.begin() + static_cast(header + 2), + frame.begin() + static_cast(header + 1 + cpl_length)); + } + + /** + * @brief Check for the bridge's connection event: function 0, software ID 0. + * + * Payload layout: [bridge index, 0x00, 0x00, connected]. The dongle sends it + * when the headset behind the bridge powers on (connected = 1) or off (0). + */ + static constexpr bool isBridgeConnectionEvent(std::span reply, uint8_t bridge_index) + { + return reply.size() >= 4 && reply[0] == bridge_index && reply[1] == 0x00; + } + + static constexpr bool isBridgeDisconnectedEvent(std::span reply, uint8_t bridge_index) + { + return isBridgeConnectionEvent(reply, bridge_index) && reply[3] == 0x00; + } + + /** + * @brief Check the parameters of a bridge status reply (bridge function 0). + * + * Parameters 2-3 count the connected headsets: 03 e8 00 01 01 while the + * headset is on, 03 e8 00 00 01 while it is off. + */ + static constexpr bool isBridgeStatusDisconnected(std::span params) + { + return params.size() >= 4 && params[2] == 0x00 && params[3] == 0x00; + } + + 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, + }; + } + static constexpr auto buildBridgeSubMessage( uint8_t sub_feature_index, uint8_t function, @@ -171,8 +319,8 @@ class LogitechCenturionProtocol : public HIDDevice { return false; } - uint8_t sub_cpl = reply_data[4]; - uint8_t sub_feature = reply_data[5]; + uint8_t sub_cpl = reply_data[4]; + uint8_t sub_feature = reply_data[5]; if (sub_cpl != 0x00) { return false; } @@ -196,7 +344,65 @@ class LogitechCenturionProtocol : public HIDDevice { return std::vector(reply_data.begin() + 7, reply_data.end()); } + private: + static constexpr std::array featureIdParams(uint16_t feature_id) + { + return { static_cast(feature_id >> 8), static_cast(feature_id & 0xFF) }; + } + + /** + * @brief Find one sub-device feature with Root.getFeature, through the bridge. + * + * Takes one request per feature (plus one for the bridge itself) instead of + * enumerating every feature. Root.getFeature replies [index, type, version]; + * index 0 means the feature does not exist. + */ + [[nodiscard]] Result lookupCenturionFeature( + hid_device* device_handle, + uint16_t feature_id) const + { + if (auto it = centurion_sub_features_.find(feature_id); it != centurion_sub_features_.end()) { + return it->second; + } + + if (!centurion_bridge_index_.has_value()) { + auto bridge_lookup = sendCenturionRequest( + device_handle, + static_cast(CenturionFeature::Root), + 0x00, + featureIdParams(static_cast(CenturionFeature::CenturionBridge))); + if (!bridge_lookup) { + return bridge_lookup.error(); + } + if (bridge_lookup->empty() || (*bridge_lookup)[0] == 0) { + return DeviceError::protocolError("Centurion bridge feature not found"); + } + centurion_bridge_index_ = (*bridge_lookup)[0]; + } + + auto feature_lookup = sendCenturionBridgeRequest( + device_handle, + static_cast(CenturionFeature::Root), + 0x00, + featureIdParams(feature_id)); + if (!feature_lookup) { + return feature_lookup.error(); + } + if (feature_lookup->empty() + || ((*feature_lookup)[0] == 0 && feature_id != static_cast(CenturionFeature::Root))) { + return DeviceError::notSupported("Centurion feature not available on this device"); + } + + CenturionFeatureInfo info { + .index = (*feature_lookup)[0], + .version = static_cast(feature_lookup->size() > 2 ? (*feature_lookup)[2] : 0), + .flags = static_cast(feature_lookup->size() > 1 ? (*feature_lookup)[1] : 0), + }; + centurion_sub_features_[feature_id] = info; + return info; + } + [[nodiscard]] Result ensureCenturionFeaturesDiscovered(hid_device* device_handle) const { if (centurion_features_discovered_) { @@ -209,8 +415,7 @@ class LogitechCenturionProtocol : public HIDDevice { 0x00, std::array { static_cast(static_cast(CenturionFeature::FeatureSet) >> 8), - static_cast(static_cast(CenturionFeature::FeatureSet) & 0xFF) - }); + static_cast(static_cast(CenturionFeature::FeatureSet) & 0xFF) }); if (!feature_set_lookup) { return feature_set_lookup.error(); } @@ -258,8 +463,7 @@ class LogitechCenturionProtocol : public HIDDevice { 0x00, std::array { static_cast(static_cast(CenturionFeature::FeatureSet) >> 8), - static_cast(static_cast(CenturionFeature::FeatureSet) & 0xFF) - }); + static_cast(static_cast(CenturionFeature::FeatureSet) & 0xFF) }); if (!sub_feature_set_lookup) { return sub_feature_set_lookup.error(); } @@ -289,7 +493,7 @@ class LogitechCenturionProtocol : public HIDDevice { continue; } - uint16_t feature_id = (static_cast((*feature_reply)[1]) << 8) | (*feature_reply)[2]; + 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), @@ -311,7 +515,10 @@ class LogitechCenturionProtocol : public HIDDevice { return DeviceError::protocolError("Centurion bridge index not initialized"); } - auto sub_message = buildBridgeSubMessageVector(sub_feature_index, function, params); + const auto options = centurionOptions(); + const auto prefix = centurionFramePrefix(); + + 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"); @@ -328,13 +535,13 @@ class LogitechCenturionProtocol : public HIDDevice { std::vector layer3 = bridge_prefix; layer3.insert(layer3.end(), sub_message.begin(), sub_message.end()); - auto frame = buildCenturionFrame(layer3); + auto frame = buildCenturionFrame(layer3, 0x00, prefix); 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; + 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; @@ -349,7 +556,7 @@ class LogitechCenturionProtocol : public HIDDevice { } layer3.insert(layer3.end(), sub_message.begin() + static_cast(offset), sub_message.begin() + static_cast(offset + chunk_size)); - auto frame = buildCenturionFrame(layer3, flags); + auto frame = buildCenturionFrame(layer3, flags, prefix); if (auto write_result = this->writeHID(device_handle, frame, frame.size()); !write_result) { return write_result.error(); } @@ -364,10 +571,14 @@ class LogitechCenturionProtocol : public HIDDevice { std::array response {}; auto read_result = this->readHIDTimeout(device_handle, response, hsc_device_timeout); if (!read_result) { + if (ack_received && options.probe_offline_on_timeout + && read_result.error().code == DeviceError::Code::Timeout) { + return probeOfflineAfterTimeout(device_handle, read_result.error()); + } return read_result.error(); } - auto payload_result = extractCenturionPayload(response); + auto payload_result = extractCenturionPayload(response, prefix); if (!payload_result) { return payload_result.error(); } @@ -377,6 +588,13 @@ class LogitechCenturionProtocol : public HIDDevice { continue; } + if (options.detect_connection_events && isBridgeConnectionEvent(reply, *centurion_bridge_index_)) { + if (isBridgeDisconnectedEvent(reply, *centurion_bridge_index_)) { + return DeviceError::deviceOffline("Headset is powered off or not connected"); + } + continue; + } + uint8_t func_sw = reply[1]; if ((func_sw >> 4) != (BRIDGE_MESSAGE_EVENT_FN >> 4)) { continue; @@ -402,7 +620,43 @@ class LogitechCenturionProtocol : public HIDDevice { return DeviceError::timeout("Timed out waiting for Centurion bridge acknowledgment"); } - return DeviceError::timeout("Timed out waiting for Centurion bridge response"); + auto timeout = DeviceError::timeout("Timed out waiting for Centurion bridge response"); + if (options.probe_offline_on_timeout) { + return probeOfflineAfterTimeout(device_handle, timeout); + } + return timeout; + } + + /** + * @brief Tell "headset off" apart from a slow reply after a bridge timeout. + * + * With the headset off, the dongle still acknowledges bridge requests, but + * nothing answers them. Ask the dongle's bridge directly whether a headset + * is connected; keep the original timeout if the answer is unclear. + */ + [[nodiscard]] Result> probeOfflineAfterTimeout( + hid_device* device_handle, + const DeviceError& timeout) const + { + auto status = sendCenturionRequest(device_handle, *centurion_bridge_index_, 0x00, {}, true); + if (status && isBridgeStatusDisconnected(*status)) { + return DeviceError::deviceOffline("Headset is powered off or not connected"); + } + if (!status && status.error().code == DeviceError::Code::DeviceOffline) { + return status.error(); + } + return timeout; + } + + static auto buildDirectPayloadVector(uint8_t feature_index, uint8_t function, std::span params) + -> std::vector + { + std::vector payload; + payload.reserve(params.size() + 2); + payload.push_back(feature_index); + payload.push_back(static_cast((function & 0xF0) | SOFTWARE_ID)); + payload.insert(payload.end(), params.begin(), params.end()); + return payload; } static constexpr auto buildDirectPayload(uint8_t feature_index, uint8_t function, std::span params) @@ -419,23 +673,9 @@ class LogitechCenturionProtocol : public HIDDevice { 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 +} // namespace headsetcontrol::protocols diff --git a/tests/test_protocols.cpp b/tests/test_protocols.cpp index cc722739..0472b0f5 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_astro_a50_gen4.hpp" +#include "devices/logitech_g522_lightspeed.hpp" #include "devices/logitech_gpro_x2_lightspeed.hpp" #include "devices/plantronics_bt600.hpp" #include "devices/protocols/hidpp_protocol.hpp" @@ -1328,6 +1329,525 @@ void testAstroA50Gen4TimeoutRecovery() } } +// ============================================================================ +// Logitech Centurion: G522 framing and opt-in options +// ============================================================================ + +class TestableG522 : public LogitechG522Lightspeed { +public: + mutable ScriptedHIDInterface hid; + + [[nodiscard]] auto getHIDInterface() const -> HIDInterface& override { return hid; } +}; + +class TestableProX2 : public LogitechGProX2Lightspeed { +public: + mutable ScriptedHIDInterface hid; + + [[nodiscard]] auto getHIDInterface() const -> HIDInterface& override { return hid; } +}; + +static constexpr std::array PRO_X2_PREFIX { 0x51 }; + +static std::vector centurionFrame(std::span prefix, std::vector payload) +{ + auto frame = protocols::LogitechCenturionProtocol::buildCenturionFrame(payload, 0x00, prefix); + return { frame.begin(), frame.end() }; +} + +/// Queue a bridge ACK followed by the sub-device's response carrying params. +static void queueBridgeReply(ScriptedHIDInterface& hid, std::span prefix, uint8_t sub_index, std::vector params) +{ + const auto size = static_cast(params.size() + 3); + std::vector payload { 0x03, 0x10, 0x00, size, 0x00, sub_index, 0x01 }; + payload.insert(payload.end(), params.begin(), params.end()); + hid.replies.push_back(centurionFrame(prefix, { 0x03, 0x11 })); + hid.replies.push_back(centurionFrame(prefix, payload)); +} + +/// Queue discovery replies: dongle bridge at index 3, sub-device battery (0x0104) at index 2. +static void queueCenturionDiscovery(ScriptedHIDInterface& hid, std::span prefix) +{ + hid.replies.push_back(centurionFrame(prefix, { 0x00, 0x01, 0x01, 0x00, 0x02 })); // Root.getFeature(FeatureSet) -> 1 + hid.replies.push_back(centurionFrame(prefix, { 0x01, 0x01, 0x04 })); // FeatureSet.getCount -> 4 + hid.replies.push_back(centurionFrame(prefix, { 0x01, 0x11, 0x04, 0x00, 0x00 })); // index 0: Root + hid.replies.push_back(centurionFrame(prefix, { 0x01, 0x11, 0x03, 0x00, 0x01 })); // index 1: FeatureSet + hid.replies.push_back(centurionFrame(prefix, { 0x01, 0x11, 0x02, 0x01, 0x00 })); // index 2: 0x0100 + hid.replies.push_back(centurionFrame(prefix, { 0x01, 0x11, 0x01, 0x00, 0x03 })); // index 3: bridge + + queueBridgeReply(hid, prefix, 0x00, { 0x01, 0x00, 0x02 }); // sub Root.getFeature(FeatureSet) -> 1 + queueBridgeReply(hid, prefix, 0x01, { 0x03 }); // sub FeatureSet.getCount -> 3 + queueBridgeReply(hid, prefix, 0x01, { 0x03, 0x00, 0x00 }); // sub index 0: Root + queueBridgeReply(hid, prefix, 0x01, { 0x02, 0x00, 0x01 }); // sub index 1: FeatureSet + queueBridgeReply(hid, prefix, 0x01, { 0x01, 0x01, 0x04 }); // sub index 2: battery +} + +static constexpr size_t CENTURION_DISCOVERY_WRITES = 11; + +/// Queue G522 lookup replies: Root.getFeature finds the bridge (index 3), then the requested +/// feature at feature_index (battery 0x0104 at index 2 by default). +static void queueG522Lookup(ScriptedHIDInterface& hid, uint8_t feature_index = 0x02) +{ + const auto prefix = std::span(LogitechG522Lightspeed::FRAME_PREFIX); + hid.replies.push_back(centurionFrame(prefix, { 0x00, 0x01, 0x03, 0x00, 0x01 })); // Root.getFeature(bridge) -> 3 + queueBridgeReply(hid, prefix, 0x00, { feature_index, 0x00, 0x03 }); // sub Root.getFeature(id) -> feature_index +} + +static constexpr size_t G522_LOOKUP_WRITES = 2; + +/// Check that a written frame starts with the expected bytes and is zero-padded after them. +static void assertFrame(const std::vector& actual, std::initializer_list expected, const std::string& msg) +{ + ASSERT_EQ(64, static_cast(actual.size()), msg + ": frame size"); + size_t i = 0; + for (uint8_t byte : expected) { + ASSERT_EQ(static_cast(byte), static_cast(actual[i]), msg + ": byte " + std::to_string(i)); + ++i; + } + for (; i < actual.size(); ++i) { + ASSERT_EQ(0, static_cast(actual[i]), msg + ": padding byte " + std::to_string(i)); + } +} + +void testCenturionG522FramePrefix() +{ + std::cout << " Testing Logitech Centurion G522 frame prefix..." << std::endl; + + // Captured from G HUB: lights on (feature 0x12, function 3). + const std::vector payload { 0x03, 0x1c, 0x00, 0x0d, 0x00, 0x12, 0x3c, 0x00, 0x00, 0x04, 0x00, 0xb8, 0xfc, 0xff, 0x00, 0xab, 0x64 }; + auto frame = protocols::LogitechCenturionProtocol::buildCenturionFrame(payload, 0x00, LogitechG522Lightspeed::FRAME_PREFIX); + assertFrame({ frame.begin(), frame.end() }, + { 0x50, 0x23, 0x12, 0x00, 0x03, 0x1c, 0x00, 0x0d, 0x00, 0x12, 0x3c, 0x00, 0x00, 0x04, 0x00, 0xb8, 0xfc, 0xff, 0x00, 0xab, 0x64 }, + "G522 frame must match the G HUB capture"); + + auto extracted = protocols::LogitechCenturionProtocol::extractCenturionPayload(frame, LogitechG522Lightspeed::FRAME_PREFIX); + ASSERT_TRUE(extracted.hasValue(), "G522 frame should extract"); + ASSERT_TRUE(*extracted == payload, "Extracted payload should round-trip"); + + auto wrong_prefix = protocols::LogitechCenturionProtocol::extractCenturionPayload(frame); + ASSERT_TRUE(wrong_prefix.hasError(), "A 0x50 0x23 frame must not extract with the default 0x51 prefix"); + + std::cout << " [OK] Logitech Centurion G522 frame prefix verified" << std::endl; +} + +void testCenturionConnectionStatusHelpers() +{ + std::cout << " Testing Logitech Centurion connection status helpers..." << std::endl; + + using Protocol = protocols::LogitechCenturionProtocol; + + // Captured on Linux: headset powered off / on (50 23 05 00 | 03 00 00 xx). + std::array off_event { 0x03, 0x00, 0x00, 0x00 }; + std::array on_event { 0x03, 0x00, 0x00, 0x01 }; + ASSERT_TRUE(Protocol::isBridgeConnectionEvent(off_event, 0x03), "Power-off frame is a connection event"); + ASSERT_TRUE(Protocol::isBridgeDisconnectedEvent(off_event, 0x03), "Power-off frame means disconnected"); + ASSERT_TRUE(Protocol::isBridgeConnectionEvent(on_event, 0x03), "Power-on frame is a connection event"); + ASSERT_TRUE(!Protocol::isBridgeDisconnectedEvent(on_event, 0x03), "Power-on frame means connected"); + ASSERT_TRUE(!Protocol::isBridgeConnectionEvent(off_event, 0x04), "Event from another feature index is ignored"); + + std::array bridge_ack { 0x03, 0x11 }; + ASSERT_TRUE(!Protocol::isBridgeConnectionEvent(bridge_ack, 0x03), "Bridge ACK is not a connection event"); + + // Captured bridge status replies (bridge function 0 parameters). + std::array status_off { 0x03, 0xe8, 0x00, 0x00, 0x01 }; + std::array status_on { 0x03, 0xe8, 0x00, 0x01, 0x01 }; + ASSERT_TRUE(Protocol::isBridgeStatusDisconnected(status_off), "Status 00 00 means disconnected"); + ASSERT_TRUE(!Protocol::isBridgeStatusDisconnected(status_on), "Status 00 01 means connected"); + ASSERT_TRUE(!Protocol::isBridgeStatusDisconnected(std::array { 0x03, 0xe8, 0x00 }), "Short status is not disconnected"); + + // G522 battery reply parameters: 47%, not charging. + auto battery = Protocol::parseCenturionBatteryResponse(std::array { 0x2f, 0x32, 0x00 }); + ASSERT_TRUE(battery.hasValue(), "G522 battery reply should parse"); + ASSERT_EQ(47, battery->level_percent, "G522 battery level"); + ASSERT_EQ(BATTERY_AVAILABLE, battery->status, "G522 battery status"); + + std::cout << " [OK] Logitech Centurion connection status helpers verified" << std::endl; +} + +void testCenturionG522BatteryWire() +{ + std::cout << " Testing Logitech G522 battery request on the wire..." << std::endl; + + TestableG522 dev; + queueG522Lookup(dev.hid); + queueBridgeReply(dev.hid, LogitechG522Lightspeed::FRAME_PREFIX, 0x02, { 0x2f, 0x32, 0x00 }); + + auto battery = dev.getBattery(nullptr); + ASSERT_TRUE(battery.hasValue(), "G522 battery should succeed"); + ASSERT_EQ(47, battery->level_percent, "G522 battery level"); + ASSERT_EQ(BATTERY_AVAILABLE, battery->status, "G522 battery status"); + + ASSERT_EQ(G522_LOOKUP_WRITES + 1, dev.hid.writes.size(), "Discovery plus one battery request"); + assertFrame(dev.hid.writes[0], { 0x50, 0x23, 0x05, 0x00, 0x00, 0x01, 0x00, 0x03 }, + "Bridge lookup is a direct Root.getFeature(0x0003) with the 50 23 prefix and the exact length"); + assertFrame(dev.hid.writes[1], { 0x50, 0x23, 0x0a, 0x00, 0x03, 0x11, 0x00, 0x05, 0x00, 0x00, 0x01, 0x01, 0x04 }, + "Battery lookup is a bridged Root.getFeature(0x0104)"); + assertFrame(dev.hid.writes[G522_LOOKUP_WRITES], { 0x50, 0x23, 0x08, 0x00, 0x03, 0x11, 0x00, 0x03, 0x00, 0x02, 0x01 }, + "Battery request goes through the bridge to the looked-up index"); + ASSERT_TRUE(dev.hid.replies.empty(), "All replies consumed"); + + // A second request reuses the cached indexes: no new lookups. + queueBridgeReply(dev.hid, LogitechG522Lightspeed::FRAME_PREFIX, 0x02, { 0x30, 0x32, 0x02 }); + auto charging = dev.getBattery(nullptr); + ASSERT_TRUE(charging.hasValue(), "Second G522 battery request should succeed"); + ASSERT_EQ(BATTERY_CHARGING, charging->status, "G522 charging state 2"); + ASSERT_EQ(G522_LOOKUP_WRITES + 2, dev.hid.writes.size(), "Cached lookup: only the battery request is sent"); + + // A feature the headset lacks (Root.getFeature returns index 0) is not supported. + TestableG522 missing; + missing.hid.replies.push_back(centurionFrame(LogitechG522Lightspeed::FRAME_PREFIX, { 0x00, 0x01, 0x03, 0x00, 0x01 })); + queueBridgeReply(missing.hid, LogitechG522Lightspeed::FRAME_PREFIX, 0x00, { 0x00, 0x00, 0x00 }); + auto unsupported = missing.getBattery(nullptr); + ASSERT_TRUE(unsupported.hasError(), "Missing feature should fail"); + ASSERT_TRUE(unsupported.error().code == DeviceError::Code::NotSupported, "Missing feature is not supported"); + + std::cout << " [OK] Logitech G522 battery request on the wire verified" << std::endl; +} + +void testCenturionG522Offline() +{ + std::cout << " Testing Logitech G522 offline detection..." << std::endl; + + const auto prefix = std::span(LogitechG522Lightspeed::FRAME_PREFIX); + + { + // Power-off event while waiting for the reply. + TestableG522 dev; + queueG522Lookup(dev.hid); + dev.hid.replies.push_back(centurionFrame(prefix, { 0x03, 0x11 })); + dev.hid.replies.push_back(centurionFrame(prefix, { 0x03, 0x00, 0x00, 0x00 })); + + auto battery = dev.getBattery(nullptr); + ASSERT_TRUE(battery.hasError(), "Power-off event should fail the request"); + ASSERT_TRUE(battery.error().code == DeviceError::Code::DeviceOffline, "Power-off event means offline"); + } + { + // Headset already off: ACK, then nothing; the bridge status probe says disconnected. + TestableG522 dev; + queueG522Lookup(dev.hid); + dev.hid.replies.push_back(centurionFrame(prefix, { 0x03, 0x11 })); + dev.hid.replies.push_back(DeviceError::timeout("No reply from the headset")); + dev.hid.replies.push_back(centurionFrame(prefix, { 0x03, 0x11 })); // stale ACK: wrong function, skipped + dev.hid.replies.push_back(centurionFrame(prefix, { 0x03, 0x01, 0x03, 0xe8, 0x00, 0x00, 0x01 })); + + auto battery = dev.getBattery(nullptr); + ASSERT_TRUE(battery.hasError(), "Unanswered request should fail"); + ASSERT_TRUE(battery.error().code == DeviceError::Code::DeviceOffline, "Probe should report offline"); + ASSERT_EQ(G522_LOOKUP_WRITES + 2, dev.hid.writes.size(), "Battery request plus one probe"); + assertFrame(dev.hid.writes.back(), { 0x50, 0x23, 0x03, 0x00, 0x03, 0x01 }, "Probe is a direct bridge function 0 request"); + ASSERT_TRUE(dev.hid.replies.empty(), "All replies consumed"); + } + { + // Probe says the headset is connected: keep the original timeout. + TestableG522 dev; + queueG522Lookup(dev.hid); + dev.hid.replies.push_back(centurionFrame(prefix, { 0x03, 0x11 })); + dev.hid.replies.push_back(DeviceError::timeout("No reply from the headset")); + dev.hid.replies.push_back(centurionFrame(prefix, { 0x03, 0x01, 0x03, 0xe8, 0x00, 0x01, 0x01 })); + + auto battery = dev.getBattery(nullptr); + ASSERT_TRUE(battery.hasError(), "Unanswered request should fail"); + ASSERT_TRUE(battery.error().code == DeviceError::Code::Timeout, "Connected headset keeps the timeout"); + } + + std::cout << " [OK] Logitech G522 offline detection verified" << std::endl; +} + +void testG522SidetoneAndAutoSleepParsing() +{ + std::cout << " Testing Logitech G522 sidetone and auto-sleep parsing..." << std::endl; + + // Captured sidetone reads after -s 0, -s 64 and -s 128. + auto off = LogitechG522Lightspeed::parseSidetoneResponse(std::array { 0x01, 0x01, 0x00, 0x00 }); + ASSERT_TRUE(off.hasValue(), "Sidetone 0 should parse"); + ASSERT_EQ(0, static_cast(off->current_level), "Step 0 reads as 0"); + ASSERT_TRUE(off->is_muted, "Step 0 is muted"); + + auto mid = LogitechG522Lightspeed::parseSidetoneResponse(std::array { 0x01, 0x01, 0x39, 0x04 }); + ASSERT_TRUE(mid.hasValue(), "Sidetone 64 should parse"); + ASSERT_EQ(4, static_cast(mid->device_level), "-s 64 is stored as step 4"); + ASSERT_EQ(56, static_cast(mid->current_level), "Step 4 reads back as 56"); + ASSERT_TRUE(!mid->is_muted, "Step 4 is not muted"); + + auto max = LogitechG522Lightspeed::parseSidetoneResponse(std::array { 0x01, 0x01, 0x48, 0x09 }); + ASSERT_TRUE(max.hasValue(), "Sidetone 128 should parse"); + ASSERT_EQ(128, static_cast(max->current_level), "Step 9 reads as 128"); + ASSERT_EQ(9, static_cast(max->device_max), "Device range is 0-9"); + + ASSERT_TRUE(LogitechG522Lightspeed::parseSidetoneResponse(std::array { 0x01, 0x01, 0x00, 0x0a }).hasError(), + "Step above 9 is rejected"); + ASSERT_TRUE(LogitechG522Lightspeed::parseSidetoneResponse(std::array { 0x01, 0x01, 0x00 }).hasError(), + "Short reply is rejected"); + + // Captured auto-sleep read: 30 min sleep, lights dim after 1 min, off after 16 min. + auto params = LogitechG522Lightspeed::buildAutoSleepParams(0x0f, std::array { 0x1e, 0x01, 0x10 }); + ASSERT_TRUE(params.hasValue(), "Auto-sleep params should build"); + ASSERT_TRUE((*params == std::array { 0x0f, 0x01, 0x10 }), "Only the sleep minutes change"); + ASSERT_TRUE(LogitechG522Lightspeed::buildAutoSleepParams(0x0f, std::array { 0x1e, 0x01 }).hasError(), + "Short auto-sleep reply is rejected"); + + std::cout << " [OK] Logitech G522 sidetone and auto-sleep parsing verified" << std::endl; +} + +void testG522SettingsWire() +{ + std::cout << " Testing Logitech G522 lights, voice prompts, sidetone and inactive time on the wire..." << std::endl; + + const auto prefix = std::span(LogitechG522Lightspeed::FRAME_PREFIX); + // Request bytes match the G HUB captures, except for the software ID nibble (1 instead of c/d). + + { + TestableG522 dev; + queueG522Lookup(dev.hid, 0x12); // lighting 0x0621 + queueBridgeReply(dev.hid, prefix, 0x12, {}); + auto on = dev.setLights(nullptr, true); + ASSERT_TRUE(on.hasValue() && on->enabled, "Lights on should succeed"); + assertFrame(dev.hid.writes.back(), + { 0x50, 0x23, 0x12, 0x00, 0x03, 0x11, 0x00, 0x0d, 0x00, 0x12, 0x31, 0x00, 0x00, 0x04, 0x00, 0xb8, 0xfc, 0xff, 0x00, 0xab, 0x64 }, + "Lights on restores G HUB's default two-zone look"); + + queueBridgeReply(dev.hid, prefix, 0x12, {}); + auto off = dev.setLights(nullptr, false); + ASSERT_TRUE(off.hasValue() && !off->enabled, "Lights off should succeed"); + assertFrame(dev.hid.writes.back(), { 0x50, 0x23, 0x12, 0x00, 0x03, 0x11, 0x00, 0x0d, 0x00, 0x12, 0x31 }, + "Lights off writes a black fixed color"); + ASSERT_EQ(G522_LOOKUP_WRITES + 2, dev.hid.writes.size(), "Second lights command reuses the lookup"); + } + { + TestableG522 dev; + queueG522Lookup(dev.hid, 0x1a); // voice prompts 0x060b + queueBridgeReply(dev.hid, prefix, 0x1a, {}); + auto voice = dev.setVoicePrompts(nullptr, true); + ASSERT_TRUE(voice.hasValue() && voice->enabled, "Voice prompts on should succeed"); + assertFrame(dev.hid.writes.back(), { 0x50, 0x23, 0x0a, 0x00, 0x03, 0x11, 0x00, 0x05, 0x00, 0x1a, 0x51, 0x00, 0x01 }, + "Voice prompts on"); + + queueBridgeReply(dev.hid, prefix, 0x1a, {}); + ASSERT_TRUE(dev.setVoicePrompts(nullptr, false).hasValue(), "Voice prompts off should succeed"); + assertFrame(dev.hid.writes.back(), { 0x50, 0x23, 0x0a, 0x00, 0x03, 0x11, 0x00, 0x05, 0x00, 0x1a, 0x51 }, + "Voice prompts off (tones)"); + } + { + TestableG522 dev; + queueG522Lookup(dev.hid, 0x0d); // sidetone 0x0604 + queueBridgeReply(dev.hid, prefix, 0x0d, { 0x01, 0x01, 0x39, 0x04 }); + auto sidetone = dev.getSidetone(nullptr); + ASSERT_TRUE(sidetone.hasValue(), "Sidetone read should succeed"); + ASSERT_EQ(4, static_cast(sidetone->device_level), "Sidetone step"); + assertFrame(dev.hid.writes.back(), { 0x50, 0x23, 0x08, 0x00, 0x03, 0x11, 0x00, 0x03, 0x00, 0x0d, 0x01 }, + "Sidetone read is function 0"); + } + { + TestableG522 dev; + queueG522Lookup(dev.hid, 0x14); // auto-sleep 0x0108 + queueBridgeReply(dev.hid, prefix, 0x14, { 0x1e, 0x01, 0x10 }); + queueBridgeReply(dev.hid, prefix, 0x14, {}); + auto inactive = dev.setInactiveTime(nullptr, 15); + ASSERT_TRUE(inactive.hasValue(), "Inactive time should succeed"); + ASSERT_EQ(G522_LOOKUP_WRITES + 2, dev.hid.writes.size(), "Lookup, read, then write"); + assertFrame(dev.hid.writes[G522_LOOKUP_WRITES], { 0x50, 0x23, 0x08, 0x00, 0x03, 0x11, 0x00, 0x03, 0x00, 0x14, 0x01 }, + "Auto-sleep read is function 0"); + assertFrame(dev.hid.writes.back(), { 0x50, 0x23, 0x0b, 0x00, 0x03, 0x11, 0x00, 0x06, 0x00, 0x14, 0x11, 0x0f, 0x01, 0x10 }, + "Auto-sleep write keeps the lighting timers"); + } + { + // A malformed read must not fall back to writing zeros. + TestableG522 dev; + queueG522Lookup(dev.hid, 0x14); + queueBridgeReply(dev.hid, prefix, 0x14, { 0x1e }); + ASSERT_TRUE(dev.setInactiveTime(nullptr, 15).hasError(), "Short auto-sleep read fails"); + ASSERT_EQ(G522_LOOKUP_WRITES + 1, dev.hid.writes.size(), "No write after a failed read"); + } + + std::cout << " [OK] Logitech G522 settings on the wire verified" << std::endl; +} + +static std::vector hexBytes(std::string_view hex) +{ + std::vector bytes; + std::istringstream stream { std::string(hex) }; + std::string token; + while (stream >> token) { + bytes.push_back(static_cast(std::stoi(token, nullptr, 16))); + } + return bytes; +} + +// Equalizer parameters captured from G HUB for each built-in preset (bytes after "0f 2d"). +static constexpr std::array G522_EQ_PRESET_CAPTURES { + "00 00 00 00 14 16 00 78 00 32 16 00 78 00 7d 16 00 78 00 fa 16 00 78 01 f4 16 00 78 03 e8 16 00 78 09 c4 16 00 78 13 88 16 00 78 27 10 16 00 78 4e 20 16 00 78", + "00 00 00 00 14 16 00 b4 00 32 16 00 aa 00 7d 16 00 9b 00 fa 20 00 8c 01 f4 16 00 78 03 e8 16 00 78 09 c4 16 00 78 13 88 16 00 78 27 10 16 00 78 4e 20 16 00 78", + "00 00 00 00 14 16 00 78 00 32 16 00 78 00 fa 16 00 82 01 90 16 00 82 03 20 16 00 87 05 dc 16 00 8c 09 c4 16 00 91 13 88 16 00 96 27 10 16 00 9b 4a 38 16 00 af", + "00 00 00 00 14 16 00 78 00 32 16 00 78 00 7d 16 00 78 01 90 16 00 64 03 20 16 00 64 04 e2 40 00 c3 09 c4 20 00 d2 13 88 16 00 9b 27 10 16 00 96 4a 38 16 00 96", + "00 00 00 00 14 16 00 c8 00 32 16 00 b4 00 7d 16 00 a0 00 fa 16 00 6e 01 f4 16 00 6e 03 e8 16 00 8c 09 c4 16 00 a0 13 88 16 00 96 27 10 16 00 82 4e 20 16 00 6e", +}; + +// Offset of band 6 (1 kHz in the Default preset) inside the parameters: 3 header bytes + 5 bands. +static constexpr size_t G522_EQ_BAND6 = 3 + 5 * 5; + +void testG522EqualizerEncoding() +{ + std::cout << " Testing Logitech G522 equalizer encoding..." << std::endl; + + using Dev = LogitechG522Lightspeed; + + for (size_t i = 0; i < Dev::EQ_PRESETS_COUNT; ++i) { + auto params = Dev::buildEqParams(*Dev::EQ_PRESETS[i]); + ASSERT_EQ(Dev::EQ_PARAMS_SIZE, params.size(), "Equalizer parameters are 53 bytes"); + ASSERT_TRUE(params == hexBytes(G522_EQ_PRESET_CAPTURES[i]), + std::string("Preset must match the G HUB capture: ") + std::string(Dev::EQ_PRESET_NAMES[i])); + } + + // Single changes at 1 kHz, captured from G HUB's custom equalizer. + auto oneBand = [](float gain_db, float q_factor, uint16_t frequency) { + auto bands = Dev::EQ_PRESET_DEFAULT; + bands[5] = { frequency, gain_db, q_factor }; + auto params = Dev::buildEqParams(bands); + return std::vector(params.begin() + G522_EQ_BAND6, params.begin() + G522_EQ_BAND6 + 5); + }; + ASSERT_TRUE((oneBand(3.0f, Dev::EQ_DEFAULT_Q, 1000) == std::vector { 0x03, 0xe8, 0x16, 0x00, 0xb4 }), "+3 dB"); + ASSERT_TRUE((oneBand(-3.0f, Dev::EQ_DEFAULT_Q, 1000) == std::vector { 0x03, 0xe8, 0x16, 0x00, 0x3c }), "-3 dB"); + ASSERT_TRUE((oneBand(6.0f, Dev::EQ_DEFAULT_Q, 1000) == std::vector { 0x03, 0xe8, 0x16, 0x00, 0xf0 }), "+6 dB"); + ASSERT_TRUE((oneBand(-6.0f, Dev::EQ_DEFAULT_Q, 1000) == std::vector { 0x03, 0xe8, 0x16, 0x00, 0x00 }), "-6 dB"); + ASSERT_TRUE((oneBand(0.0f, 1.0f, 1000) == std::vector { 0x03, 0xe8, 0x20, 0x00, 0x78 }), "Q 1.0"); + ASSERT_TRUE((oneBand(0.0f, 1.0f, 1200) == std::vector { 0x04, 0xb0, 0x20, 0x00, 0x78 }), "1200 Hz"); + ASSERT_TRUE((oneBand(0.0f, 0.688f, 1000) == std::vector { 0x03, 0xe8, 0x16, 0x00, 0x78 }), "G HUB's Q 0.688"); + + // The headset stores 0.05 dB steps. + ASSERT_EQ(0x78, static_cast(oneBand(0.01f, Dev::EQ_DEFAULT_Q, 1000)[4]), "0.01 dB rounds to 0"); + ASSERT_EQ(0x79, static_cast(oneBand(0.03f, Dev::EQ_DEFAULT_Q, 1000)[4]), "0.03 dB rounds to 0.05"); + + std::cout << " [OK] Logitech G522 equalizer encoding verified" << std::endl; +} + +void testG522EqualizerCommands() +{ + std::cout << " Testing Logitech G522 equalizer commands..." << std::endl; + + using Dev = LogitechG522Lightspeed; + const auto prefix = std::span(Dev::FRAME_PREFIX); + const std::vector eq_header { 0x50, 0x23, 0x3d, 0x00, 0x03, 0x11, 0x00, 0x38, 0x00, 0x0f, 0x21 }; + + auto writtenParams = [](const std::vector& frame) { + return std::vector(frame.begin() + 11, frame.end()); + }; + auto writtenHeader = [](const std::vector& frame) { + return std::vector(frame.begin(), frame.begin() + 11); + }; + + { + TestableG522 dev; + queueG522Lookup(dev.hid, 0x0f); // playback EQ 0x020d + queueBridgeReply(dev.hid, prefix, 0x0f, {}); + auto preset = dev.setEqualizerPreset(nullptr, 1); + ASSERT_TRUE(preset.hasValue(), "Bass Boost preset should succeed"); + ASSERT_EQ(5, static_cast(preset->total_presets), "Five presets"); + ASSERT_TRUE(writtenHeader(dev.hid.writes.back()) == eq_header, "Equalizer write header (single 64-byte frame)"); + ASSERT_TRUE(writtenParams(dev.hid.writes.back()) == hexBytes(G522_EQ_PRESET_CAPTURES[1]), "Bass Boost on the wire"); + + ASSERT_TRUE(dev.setEqualizerPreset(nullptr, 5).hasError(), "Preset 5 does not exist"); + ASSERT_EQ(G522_LOOKUP_WRITES + 1, dev.hid.writes.size(), "Invalid preset sends nothing"); + } + { + TestableG522 dev; + queueG522Lookup(dev.hid, 0x0f); + queueBridgeReply(dev.hid, prefix, 0x0f, {}); + EqualizerSettings custom { { 0, 0, 0, 0, 0, 3.0f, 0, 0, 0, 0 } }; + ASSERT_TRUE(dev.setEqualizer(nullptr, custom).hasValue(), "10 gains should succeed"); + auto expected = hexBytes(G522_EQ_PRESET_CAPTURES[0]); + expected[G522_EQ_BAND6 + 4] = 0xb4; + ASSERT_TRUE(writtenParams(dev.hid.writes.back()) == expected, "Custom gains use the Default frequencies and Q"); + + const size_t writes = dev.hid.writes.size(); + ASSERT_TRUE(dev.setEqualizer(nullptr, EqualizerSettings { { 0, 0, 0 } }).hasError(), "Wrong band count is rejected"); + ASSERT_TRUE(dev.setEqualizer(nullptr, EqualizerSettings { { 0, 0, 0, 0, 0, 6.5f, 0, 0, 0, 0 } }).hasError(), + "Gain above +6 dB is rejected"); + ASSERT_TRUE(dev.setEqualizer(nullptr, EqualizerSettings { { 0, 0, 0, 0, 0, std::numeric_limits::quiet_NaN(), 0, 0, 0, 0 } }).hasError(), + "NaN gain is rejected"); + ASSERT_EQ(writes, dev.hid.writes.size(), "Rejected settings send nothing"); + } + { + TestableG522 dev; + queueG522Lookup(dev.hid, 0x0f); + queueBridgeReply(dev.hid, prefix, 0x0f, {}); + ASSERT_TRUE(dev.setParametricEqualizer(nullptr, ParametricEqualizerSettings {}).hasValue(), "Reset should succeed"); + ASSERT_TRUE(writtenParams(dev.hid.writes.back()) == hexBytes(G522_EQ_PRESET_CAPTURES[0]), "Reset restores Default"); + + // Band 6 moved to 1200 Hz with Q 1.0, as in the eq-freq-1200 capture. + queueBridgeReply(dev.hid, prefix, 0x0f, {}); + ParametricEqualizerSettings moved; + for (const auto& band : Dev::EQ_PRESET_DEFAULT) { + moved.bands.push_back({ static_cast(band.frequency), band.gain_db, band.q_factor, EqualizerFilterType::Peaking }); + } + moved.bands[5] = { 1200.0f, 0.0f, 1.0f, EqualizerFilterType::Peaking }; + ASSERT_TRUE(dev.setParametricEqualizer(nullptr, moved).hasValue(), "10 parametric bands should succeed"); + auto expected = hexBytes(G522_EQ_PRESET_CAPTURES[0]); + expected[G522_EQ_BAND6 + 0] = 0x04; + expected[G522_EQ_BAND6 + 1] = 0xb0; + expected[G522_EQ_BAND6 + 2] = 0x20; + ASSERT_TRUE(writtenParams(dev.hid.writes.back()) == expected, "Parametric band matches the eq-freq-1200 capture"); + + queueBridgeReply(dev.hid, prefix, 0x0f, {}); + ParametricEqualizerSettings limits { { { 20.0f, -6.0f, 0.031f, EqualizerFilterType::Peaking }, + { 20000.0f, 6.0f, 7.969f, EqualizerFilterType::Peaking } } }; + ASSERT_TRUE(dev.setParametricEqualizer(nullptr, limits).hasValue(), "G HUB's displayed limits are accepted"); + + const size_t writes = dev.hid.writes.size(); + auto rejects = [&](ParametricEqualizerBand band, const char* msg) { + ASSERT_TRUE(dev.setParametricEqualizer(nullptr, ParametricEqualizerSettings { { band } }).hasError(), msg); + }; + rejects({ 1000.0f, 0.0f, 1.0f, EqualizerFilterType::LowShelf }, "Only peaking bands are supported"); + rejects({ 15.0f, 0.0f, 1.0f, EqualizerFilterType::Peaking }, "Frequency below 20 Hz is rejected"); + rejects({ 1000.0f, -6.5f, 1.0f, EqualizerFilterType::Peaking }, "Gain below -6 dB is rejected"); + rejects({ 1000.0f, 0.0f, 8.1f, EqualizerFilterType::Peaking }, "Q above 7.969 is rejected"); + rejects({ 1000.0f, 0.0f, 0.01f, EqualizerFilterType::Peaking }, "Q below 0.031 is rejected"); + ASSERT_TRUE(dev.setParametricEqualizer(nullptr, ParametricEqualizerSettings { std::vector(11) }).hasError(), + "More than 10 bands is rejected"); + ASSERT_EQ(writes, dev.hid.writes.size(), "Rejected settings send nothing"); + } + + std::cout << " [OK] Logitech G522 equalizer commands verified" << std::endl; +} + +void testCenturionProX2DefaultOptionsUnchanged() +{ + std::cout << " Testing Logitech PRO X2 traffic with default Centurion options..." << std::endl; + + { + TestableProX2 dev; + queueCenturionDiscovery(dev.hid, PRO_X2_PREFIX); + queueBridgeReply(dev.hid, PRO_X2_PREFIX, 0x02, { 0x2f, 0x32, 0x00 }); + + auto battery = dev.getBattery(nullptr); + ASSERT_TRUE(battery.hasValue(), "PRO X2 battery should succeed"); + ASSERT_EQ(47, battery->level_percent, "PRO X2 battery level"); + + // The direct request keeps the historic length byte of the full 64-byte payload buffer (0x41). + std::vector expected_direct(64, 0x00); + expected_direct[0] = 0x51; + expected_direct[1] = 0x41; + expected_direct[4] = 0x01; + expected_direct[6] = 0x01; + ASSERT_TRUE(dev.hid.writes[0] == expected_direct, "PRO X2 direct request must be byte-identical to before"); + assertFrame(dev.hid.writes[CENTURION_DISCOVERY_WRITES], { 0x51, 0x08, 0x00, 0x03, 0x11, 0x00, 0x03, 0x00, 0x02, 0x01 }, + "PRO X2 bridge request is unchanged"); + } + { + // A power-off event is skipped, and a timeout is returned without a probe. + TestableProX2 dev; + queueCenturionDiscovery(dev.hid, PRO_X2_PREFIX); + dev.hid.replies.push_back(centurionFrame(PRO_X2_PREFIX, { 0x03, 0x11 })); + dev.hid.replies.push_back(centurionFrame(PRO_X2_PREFIX, { 0x03, 0x00, 0x00, 0x00 })); + + auto battery = dev.getBattery(nullptr); + ASSERT_TRUE(battery.hasError(), "Unanswered request should fail"); + ASSERT_TRUE(battery.error().code == DeviceError::Code::Timeout, "PRO X2 keeps reporting a timeout"); + ASSERT_EQ(CENTURION_DISCOVERY_WRITES + 1, dev.hid.writes.size(), "PRO X2 sends no probe"); + } + + std::cout << " [OK] Logitech PRO X2 traffic with default Centurion options verified" << std::endl; +} + // ============================================================================ // Test Runner // ============================================================================ @@ -1384,6 +1904,15 @@ void runAllProtocolTests() runTest("Logitech Centurion Frame Building", testCenturionFrameBuilding); runTest("Logitech Centurion Bridge Parsing", testCenturionBridgeResponseParsing); runTest("Logitech Centurion Bridge Size Limit", testCenturionBridgeMessageSizeLimit); + runTest("Logitech Centurion G522 Frame Prefix", testCenturionG522FramePrefix); + runTest("Logitech Centurion Connection Status", testCenturionConnectionStatusHelpers); + runTest("Logitech G522 Battery Wire", testCenturionG522BatteryWire); + runTest("Logitech G522 Offline Detection", testCenturionG522Offline); + runTest("Logitech G522 Sidetone And Auto-Sleep Parsing", testG522SidetoneAndAutoSleepParsing); + runTest("Logitech G522 Settings Wire", testG522SettingsWire); + runTest("Logitech G522 Equalizer Encoding", testG522EqualizerEncoding); + runTest("Logitech G522 Equalizer Commands", testG522EqualizerCommands); + runTest("Logitech PRO X2 Default Centurion Options", testCenturionProX2DefaultOptionsUnchanged); runTest("Logitech PRO X2 Equalizer Info Cache", testLogitechProX2EqualizerInfoRequiresDescriptor); runTest("Logitech PRO X2 EQ Quantization", testLogitechProX2OnboardEqCoefficientQuantization); runTest("Logitech PRO X2 Onboard EQ Payload", testLogitechProX2OnboardEqPayloadBuilding);