diff --git a/README.md b/README.md index 58006bc..f5bf269 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ sudo udevadm control --reload-rules && sudo udevadm trigger | Device | Platform | sidetone | battery | notification sound | lights | inactive time | chatmix | voice prompts | rotate to mute | equalizer preset | equalizer | parametric equalizer | microphone mute led brightness | microphone volume | volume limiter | bluetooth when powered on | bluetooth call volume | microphone noise filter | sidetone status | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 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 G533 | All | x | x | | | x | | | | | | | | | | | | | | diff --git a/cli/main.cpp b/cli/main.cpp index 20daaa2..bf70e8d 100644 --- a/cli/main.cpp +++ b/cli/main.cpp @@ -384,6 +384,7 @@ class HIDConnection { HIDConnection(HIDConnection&& other) noexcept : handle_(other.handle_) + , device_(other.device_) , path_(std::move(other.path_)) { other.handle_ = nullptr; @@ -393,6 +394,7 @@ class HIDConnection { { if (this != &other) { close(); + device_ = other.device_; handle_ = other.handle_; path_ = std::move(other.path_); other.handle_ = nullptr; @@ -403,14 +405,15 @@ class HIDConnection { [[nodiscard]] bool isOpen() const { return handle_ != nullptr; } [[nodiscard]] hid_device* get() const { return handle_; } - bool open(const std::string& new_path) + bool open(const std::string& new_path, const HIDDevice* device) { - if (path_ == new_path && handle_) + if (path_ == new_path && handle_ && device_ == device) return true; close(); handle_ = hid_open_path(new_path.c_str()); if (handle_) { - path_ = new_path; + device_ = device; + path_ = new_path; return true; } return false; @@ -419,6 +422,9 @@ class HIDConnection { void close() { if (handle_) { + if (device_) { + device_->onConnectionClosed(handle_); + } hid_close(handle_); handle_ = nullptr; } @@ -426,7 +432,8 @@ class HIDConnection { } private: - hid_device* handle_ = nullptr; + hid_device* handle_ = nullptr; + const HIDDevice* device_ = nullptr; std::string path_; }; @@ -548,7 +555,7 @@ hid_device* connectForCapability(HIDConnection& conn, const HIDDevice* device, u if (!hid_path) return nullptr; - return conn.open(*hid_path) ? conn.get() : nullptr; + return conn.open(*hid_path, device) ? conn.get() : nullptr; } // Convert FeatureOutput to FeatureResult for output formatting diff --git a/lib/device_registry.cpp b/lib/device_registry.cpp index 5c9dc86..7312460 100644 --- a/lib/device_registry.cpp +++ b/lib/device_registry.cpp @@ -7,6 +7,7 @@ // Logitech devices #include "devices/logitech_astro_a50.hpp" +#include "devices/logitech_astro_a50_gen4.hpp" // Logitech devices with HIDPPDevice protocol template #include "devices/logitech_g432.hpp" @@ -107,6 +108,7 @@ void DeviceRegistry::initialize() // Each device is managed by a unique_ptr for automatic cleanup // Logitech devices + registerDevice(std::make_unique()); registerDevice(std::make_unique()); // Logitech devices (using HIDPPDevice protocol template) diff --git a/lib/devices/hid_device.hpp b/lib/devices/hid_device.hpp index 326948c..cec3d6f 100644 --- a/lib/devices/hid_device.hpp +++ b/lib/devices/hid_device.hpp @@ -83,6 +83,15 @@ class HIDDevice { return { .usagepage = 0, .usageid = 0, .interface_id = 0 }; } + /** + * @brief Release per-connection protocol state when a HID handle is closed. + * + * Optional. Devices that keep per-handle state recover on their own if this is + * skipped; calling it before hid_close() avoids a bounded delay on the next + * request if the handle address is reused. + */ + virtual void onConnectionClosed(hid_device* /*device_handle*/) const { } + /** * @brief Get equalizer presets count */ diff --git a/lib/devices/logitech_astro_a50_gen4.hpp b/lib/devices/logitech_astro_a50_gen4.hpp new file mode 100644 index 0000000..f8a4fe3 --- /dev/null +++ b/lib/devices/logitech_astro_a50_gen4.hpp @@ -0,0 +1,640 @@ +#pragma once + +#include "device_utils.hpp" +#include "hid_device.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std::string_view_literals; + +namespace headsetcontrol { + +/** + * @brief Logitech ASTRO A50 Gen 4 base station — USB ID 9886:002c + * + * Logitech-era ASTRO product on Astro Gaming's legacy vendor ID. Verified on the + * PlayStation/PC edition only; whether the Xbox/PC edition shares 002c is unknown. The + * base's mode switch must be on PC: in console mode it re-enumerates as 9886:002b with + * audio interfaces only and no HID. Hardware-verified on Linux only; Windows and macOS + * are untested. + * + * Transport: one vendor HID interface (interface 6, usage page 0xFF32 / usage 0x74), + * report ID 0x02, 64-byte frames, strictly one reply per request, no unsolicited frames + * (unlike the Gen 5). After a timeout, the next request on that connection first waits + * briefly for the outstanding reply and discards it. Command codes come from the + * MIT-licensed eh-fifty project and were re-verified on hardware. + * + * Settings are not saved to flash: every write changes the active value only and is + * lost when the base station power-cycles. + * + * Request: 02 CMD [LEN PAYLOAD...] 00-padded + * Reply: 02 STATUS LEN PAYLOAD... STATUS 0x02 = OK with payload, + * 0x00 = accepted without payload, 0x01 = error (PAYLOAD[0] = code, + * PAYLOAD[4..] = NUL-terminated ASCII name). LEN is not trusted: the + * firmware occasionally reports a wrong value. + * + * Capabilities: + * - Battery (0x7C): bit 7 charging, bits 0-6 percent; gated on the 0x54 link bit because + * the base serves the last known level after the headset powers off. + * - Chatmix (0x72): 0 = full voice .. 255 = full game on a 5-step grid; same gating. + * - Sidetone (0x62/0x68, slider 0x05) and microphone volume (0x62, slider 0x04): 0..100. + * - Noise filter (0x64): HSC 0/1/2 -> Streaming / Night / Tournament. + * - EQ preset (0x67/0x6C): three presets; the read-back lags the ack by a few hundred ms, + * so the setter polls until it agrees. + * - Basic EQ (0x63): five gains on the active preset, preserving frequencies and bandwidths. + * - Parametric EQ (0x6F bands, then 0x63 gains) on the active preset: five bands, gain + * byte = dB + 12 for -7..+7 dB, bandwidth = 4096 / Q, bands 1 and 5 are shelves (bw 0). + * Firmware enforces 80..15000 Hz (HID_ERROR_FC_NOT_VALID) but accepts any bandwidth; + * Q limits are derived from eh-fifty's bandwidth range: 4096/12288..4096/409. + * + * Not implemented: + * - SAVE_VALUES (0x61) copies the active values into the saved ones (0x68 reports both) and + * is never sent; every write here changes the active value only. + * - Auto-shutoff (0x74/0x78) and brightness (0x75/0x79) are eh-fifty's labels for two + * commands whose semantics could not be confirmed. + * - Controls with no HeadsetControl capability, documented by eh-fifty and not exercised + * here: alert volume (0x76/0x7A), microphone EQ preset (0x71/0x7B), default balance + * (0x73/0x77), EQ preset names (0x6D/0x6E), device and firmware info (0x03, 0x55, 0xD6, + * 0xDA), and the fourth noise gate mode HOME. + * The device accepts out-of-range slider values unchanged (255 reads back as 255), so this + * class clamps before writing. + */ +class LogitechAstroA50Gen4 : public HIDDevice { +public: + static constexpr uint16_t VENDOR_ASTRO = 0x9886; + static constexpr std::array PRODUCT_IDS { 0x002c }; + + // Frame structure + static constexpr uint8_t REPORT_ID = 0x02; + static constexpr size_t FRAME_SIZE = 64; + static constexpr size_t PAYLOAD_OFFSET = 3; + + // Reply status (byte 1) + static constexpr uint8_t STATUS_ACCEPTED = 0x00; + static constexpr uint8_t STATUS_ERROR = 0x01; + static constexpr uint8_t STATUS_OK = 0x02; + + // Commands + static constexpr uint8_t CMD_HEADSET_STATUS = 0x54; + static constexpr uint8_t CMD_SET_SLIDER = 0x62; + static constexpr uint8_t CMD_SET_EQ_GAIN = 0x63; + static constexpr uint8_t CMD_SET_NOISE_GATE = 0x64; + static constexpr uint8_t CMD_SET_EQ_PRESET = 0x67; + static constexpr uint8_t CMD_GET_SLIDER = 0x68; + static constexpr uint8_t CMD_GET_EQ_PRESET = 0x6C; + static constexpr uint8_t CMD_SET_EQ_BAND = 0x6F; + static constexpr uint8_t CMD_GET_BALANCE = 0x72; + static constexpr uint8_t CMD_GET_BATTERY = 0x7C; + + // Slider ids + static constexpr uint8_t SLIDER_MIC = 0x04; + static constexpr uint8_t SLIDER_SIDETONE = 0x05; + static constexpr uint8_t SLIDER_MAX = 100; + + // 0x54 status: bit 0 = docked, bit 1 = live link to the headset + static constexpr uint8_t STATUS_LINKED = 0x02; + + // Balance (0x72): 0 = full voice, 255 = full game, moves on a 5-step grid. + // The physical midpoint reads 130 (nearest grid point to 127.5), so raw values + // within one grid step of centre are reported as exactly balanced. + static constexpr int BALANCE_MAX = 255; + static constexpr int BALANCE_CENTRE_LOW = 125; + static constexpr int BALANCE_CENTRE_HIGH = 130; + + // Noise gate: device has 4 modes; HeadsetControl 0/1/2 -> Streaming / Night / Tournament + static constexpr std::array NOISE_LEVELS { 0x00, 0x01, 0x03 }; + + // Equalizer: presets are numbered 1..3 on the wire (HeadsetControl 0..2). Each preset has + // five bands; gain byte = dB + 12; bandwidth = (BW / f0) × 4096, 0 for the shelf bands. + static constexpr uint8_t EQ_PRESETS = 3; + static constexpr int EQ_BANDS = 5; + static constexpr int EQ_GAIN_OFFSET = 12; + static constexpr float EQ_GAIN_MIN_DB = -7.0f; + static constexpr float EQ_GAIN_MAX_DB = 7.0f; + static constexpr float EQ_BW_SCALE = 4096.0f; + static constexpr uint16_t EQ_BW_MIN = 409; // 0.1 × f0 + static constexpr uint16_t EQ_BW_MAX = 12288; // 3.0 × f0 + static constexpr float EQ_Q_MIN = EQ_BW_SCALE / EQ_BW_MAX; // ≈ 0.33 + static constexpr float EQ_Q_MAX = EQ_BW_SCALE / EQ_BW_MIN; // ≈ 10 + static constexpr int EQ_FREQ_MIN = 80; + static constexpr int EQ_FREQ_MAX = 15000; + static constexpr int EQ_PRESET_POLL_MS = 200; + static constexpr int EQ_PRESET_POLL_LIMIT = 8; + + uint16_t getVendorId() const override { return VENDOR_ASTRO; } + + std::vector getProductIds() const override + { + return { PRODUCT_IDS.begin(), PRODUCT_IDS.end() }; + } + + std::string_view getDeviceName() const override { return "Logitech ASTRO A50 Gen 4"sv; } + + constexpr int getCapabilities() const override + { + return B(CAP_BATTERY_STATUS) | B(CAP_CHATMIX_STATUS) | B(CAP_SIDETONE) + | B(CAP_SIDETONE_STATUS) | B(CAP_MICROPHONE_VOLUME) | B(CAP_NOISE_FILTER) + | B(CAP_EQUALIZER_PRESET) | B(CAP_EQUALIZER) | B(CAP_PARAMETRIC_EQUALIZER); + } + + uint8_t getEqualizerPresetsCount() const override { return EQ_PRESETS; } + + std::optional getEqualizerInfo() const override + { + return EqualizerInfo { + .bands_count = EQ_BANDS, + .bands_baseline = 0, + .bands_step = 1.0f, + .bands_min = static_cast(EQ_GAIN_MIN_DB), + .bands_max = static_cast(EQ_GAIN_MAX_DB), + }; + } + + void onConnectionClosed(hid_device* device_handle) const override + { + const std::lock_guard lock(request_mutex_); + pending_replies_.erase(device_handle); + } + + std::optional getParametricEqualizerInfo() const override + { + return ParametricEqualizerInfo { + .bands_count = EQ_BANDS, + .gain_base = 0.0f, + .gain_step = 1.0f, + .gain_min = EQ_GAIN_MIN_DB, + .gain_max = EQ_GAIN_MAX_DB, + .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::LowShelf)) + | B(static_cast(EqualizerFilterType::Peaking)) + | B(static_cast(EqualizerFilterType::HighShelf)), + }; + } + + constexpr capability_detail getCapabilityDetail([[maybe_unused]] enum capabilities cap) const override + { + // Control lives on the vendor HID: interface 6 (Linux/macOS), + // usage page 0xFF32 / usage 0x74 (Windows). + return { .usagepage = 0xff32, .usageid = 0x0074, .interface_id = 6 }; + } + + // ------------------------------------------------------------------------ + // Pure helpers (public so they can be unit-tested without hardware) + // ------------------------------------------------------------------------ + + // Build a 64-byte request frame: 02 CMD [LEN PAYLOAD...] zero-padded. + [[nodiscard]] static std::array buildFrame(uint8_t cmd, std::span payload) + { + std::array frame {}; + frame[0] = REPORT_ID; + frame[1] = cmd; + if (!payload.empty()) { + frame[2] = static_cast(payload.size()); + for (size_t i = 0; i < payload.size() && (PAYLOAD_OFFSET + i) < FRAME_SIZE; ++i) { + frame[PAYLOAD_OFFSET + i] = payload[i]; + } + } + return frame; + } + + // Battery byte (0x7C payload[0]): bit 7 = charging, bits 0-6 = percent. + [[nodiscard]] static constexpr int batteryPercent(uint8_t raw) { return raw & 0x7f; } + [[nodiscard]] static constexpr bool batteryCharging(uint8_t raw) { return (raw & 0x80) != 0; } + + // 0x54 payload[0] bit 1: the base has a live link to the headset. + [[nodiscard]] static constexpr bool isLinked(uint8_t status) { return (status & STATUS_LINKED) != 0; } + + // Map a raw balance (0 = voice .. 255 = game) to HeadsetControl's 0..128 (< 64 = game). + [[nodiscard]] static constexpr int balanceToLevel(uint8_t raw) + { + if (raw >= BALANCE_CENTRE_LOW && raw <= BALANCE_CENTRE_HIGH) { + return 64; + } + return map(raw, 0, BALANCE_MAX, 128, 0); + } + + // Gain byte for the 0x63 payload: dB + 12, so -7..+7 dB -> 5..19. + [[nodiscard]] static uint8_t gainToByte(float gain_db) + { + return static_cast(std::lround(gain_db) + EQ_GAIN_OFFSET); + } + + // Bandwidth field for the 0x6F payload: (BW / f0) × 4096 = 4096 / Q. The caller validates Q; + // the clamp only guards the rounding at the edges of the range. + [[nodiscard]] static uint16_t qToBandwidth(float q_factor) + { + const auto raw = std::lround(EQ_BW_SCALE / q_factor); + return static_cast(std::clamp(raw, EQ_BW_MIN, EQ_BW_MAX)); + } + + // The filter type each band must have: shelves at the edges, peaking in between. + [[nodiscard]] static constexpr EqualizerFilterType bandType(int band) + { + if (band == 0) { + return EqualizerFilterType::LowShelf; + } + if (band == EQ_BANDS - 1) { + return EqualizerFilterType::HighShelf; + } + return EqualizerFilterType::Peaking; + } + + // Extract the NUL-terminated ASCII error name from an error reply. + [[nodiscard]] static std::string errorName(std::span reply) + { + constexpr size_t NAME_OFFSET = PAYLOAD_OFFSET + 4; + std::string name; + for (size_t i = NAME_OFFSET; i < FRAME_SIZE && reply[i] != 0; ++i) { + const auto c = reply[i]; + name.push_back((c >= 0x20 && c < 0x7f) ? static_cast(c) : '?'); + } + return name; + } + + // ------------------------------------------------------------------------ + // Capabilities + // ------------------------------------------------------------------------ + + Result getBattery(hid_device* device_handle) override + { + auto linked = queryLinked(device_handle); + if (!linked) { + return linked.error(); + } + if (!*linked) { + // The base keeps serving the last known level after the headset powers off. + return BatteryResult { .level_percent = -1, .status = BATTERY_UNAVAILABLE }; + } + + auto reply = sendRequest(device_handle, CMD_GET_BATTERY, {}, /*expect_payload=*/true); + if (!reply) { + return reply.error(); + } + + const uint8_t raw = (*reply)[PAYLOAD_OFFSET]; + return BatteryResult { + .level_percent = batteryPercent(raw), + .status = batteryCharging(raw) ? BATTERY_CHARGING : BATTERY_AVAILABLE, + }; + } + + Result getChatmix(hid_device* device_handle) override + { + auto linked = queryLinked(device_handle); + if (!linked) { + return linked.error(); + } + if (!*linked) { + // 0x72 returns a frozen, impossible value (0xfd) with no link. + return DeviceError::deviceOffline("ASTRO A50 Gen 4: headset is not linked to the base station"); + } + + auto reply = sendRequest(device_handle, CMD_GET_BALANCE, {}, /*expect_payload=*/true); + if (!reply) { + return reply.error(); + } + + const int level = balanceToLevel((*reply)[PAYLOAD_OFFSET]); + const int game_pct = (level <= 64) ? 100 : map(level, 64, 128, 100, 0); + const int chat_pct = (level >= 64) ? 100 : map(level, 0, 64, 0, 100); + + return ChatmixResult { + .level = level, + .game_volume_percent = game_pct, + .chat_volume_percent = chat_pct, + }; + } + + Result setSidetone(hid_device* device_handle, uint8_t level) override + { + const uint8_t device_level = map(level, 0, 128, 0, SLIDER_MAX); + if (auto r = setSlider(device_handle, SLIDER_SIDETONE, device_level); !r) { + return r.error(); + } + + return SidetoneResult { + .current_level = level, + .min_level = 0, + .max_level = 128, + .device_min = 0, + .device_max = SLIDER_MAX, + .device_level = device_level, + }; + } + + Result getSidetone(hid_device* device_handle) override + { + // Reply payload: 68 + const std::array payload { SLIDER_SIDETONE }; + auto reply = sendRequest(device_handle, CMD_GET_SLIDER, payload, /*expect_payload=*/true); + if (!reply) { + return reply.error(); + } + if ((*reply)[PAYLOAD_OFFSET] != CMD_GET_SLIDER || (*reply)[PAYLOAD_OFFSET + 1] != SLIDER_SIDETONE) { + return DeviceError::protocolError("ASTRO A50 Gen 4: unexpected slider reply"); + } + + const uint8_t device_level = (*reply)[PAYLOAD_OFFSET + 2]; + return SidetoneResult { + .current_level = map(device_level, 0, SLIDER_MAX, 0, 128), + .min_level = 0, + .max_level = 128, + .device_min = 0, + .device_max = SLIDER_MAX, + .device_level = device_level, + }; + } + + Result setMicVolume(hid_device* device_handle, uint8_t volume) override + { + const uint8_t device_level = map(volume, 0, 128, 0, SLIDER_MAX); + if (auto r = setSlider(device_handle, SLIDER_MIC, device_level); !r) { + return r.error(); + } + + return MicVolumeResult { + .volume = volume, + .min_volume = 0, + .max_volume = 128, + }; + } + + Result setNoiseFilter(hid_device* device_handle, uint8_t level) override + { + if (level >= NOISE_LEVELS.size()) { + return DeviceError::invalidParameter("Noise filter level must be 0, 1, or 2"); + } + + // Reply payload[0] echoes the mode, not the command. + const std::array payload { NOISE_LEVELS[level] }; + auto reply = sendRequest(device_handle, CMD_SET_NOISE_GATE, payload, /*expect_payload=*/false); + if (!reply) { + return reply.error(); + } + if ((*reply)[1] == STATUS_OK && (*reply)[PAYLOAD_OFFSET] != NOISE_LEVELS[level]) { + return DeviceError::protocolError("ASTRO A50 Gen 4: noise gate mode was not echoed back"); + } + + return NoiseFilterResult { .level = level }; + } + + Result setEqualizerPreset(hid_device* device_handle, uint8_t preset) override + { + if (preset >= EQ_PRESETS) { + return DeviceError::invalidParameter("ASTRO A50 Gen 4 has presets 0, 1 and 2"); + } + const uint8_t device_preset = static_cast(preset + 1); + + // Reply payload: 67 + const std::array payload { device_preset }; + auto reply = sendRequest(device_handle, CMD_SET_EQ_PRESET, payload, /*expect_payload=*/false); + if (!reply) { + return reply.error(); + } + if ((*reply)[1] == STATUS_OK && (*reply)[PAYLOAD_OFFSET + 1] != device_preset) { + return DeviceError::protocolError("ASTRO A50 Gen 4: preset write was not echoed back"); + } + + // The base acknowledges immediately but reports the old preset for a few hundred + // milliseconds. Poll until the read-back agrees rather than trusting the echo. + for (int attempt = 0; attempt < EQ_PRESET_POLL_LIMIT; ++attempt) { + std::this_thread::sleep_for(std::chrono::milliseconds(EQ_PRESET_POLL_MS)); + auto active = readActivePreset(device_handle); + if (!active) { + return active.error(); + } + if (*active == device_preset) { + return EqualizerPresetResult { .preset = preset, .total_presets = EQ_PRESETS }; + } + } + return DeviceError::protocolError("ASTRO A50 Gen 4: preset change was not confirmed by the base station"); + } + + Result setEqualizer(hid_device* device_handle, const EqualizerSettings& settings) override + { + if (settings.size() != EQ_BANDS) { + return DeviceError::invalidParameter("ASTRO A50 Gen 4 requires exactly 5 equalizer gains"); + } + for (float gain : settings.bands) { + if (!validGain(gain)) { + return DeviceError::invalidParameter("Gain must be finite and between -7 dB and +7 dB"); + } + } + auto active = readActivePreset(device_handle); + if (!active) { + return active.error(); + } + if (auto result = setEqGains(device_handle, *active, settings.bands); !result) { + return result.error(); + } + return EqualizerResult {}; + } + + Result setParametricEqualizer( + hid_device* device_handle, const ParametricEqualizerSettings& settings) override + { + if (settings.size() != EQ_BANDS) { + return DeviceError::invalidParameter("ASTRO A50 Gen 4 requires exactly 5 parametric EQ bands"); + } + for (int i = 0; i < EQ_BANDS; ++i) { + const auto& band = settings.bands[i]; + if (band.type != bandType(i)) { + return DeviceError::invalidParameter( + "ASTRO A50 Gen 4 bands must be lowshelf, peaking, peaking, peaking, highshelf"); + } + if (!std::isfinite(band.frequency) || band.frequency < EQ_FREQ_MIN || band.frequency > EQ_FREQ_MAX) { + return DeviceError::invalidParameter("Frequency must be between 80 Hz and 15000 Hz"); + } + if (!validGain(band.gain)) { + return DeviceError::invalidParameter("Gain must be between -7 dB and +7 dB"); + } + if (band.type == EqualizerFilterType::Peaking && (!std::isfinite(band.q_factor) || band.q_factor < EQ_Q_MIN || band.q_factor > EQ_Q_MAX)) { + return DeviceError::invalidParameter( + std::format("Q factor must be finite and between {} and {}", EQ_Q_MIN, EQ_Q_MAX)); + } + } + + // Bands are stored per preset, so write to whichever preset is active. + auto active = readActivePreset(device_handle); + if (!active) { + return active.error(); + } + const uint8_t device_preset = *active; + + // 0x6F payload: ; reply payload: 6F ... + for (int i = 0; i < EQ_BANDS; ++i) { + const auto& band = settings.bands[i]; + const uint16_t bw = (band.type == EqualizerFilterType::Peaking) ? qToBandwidth(band.q_factor) : 0; + const auto freq = static_cast(std::lround(band.frequency)); + const std::array payload { + device_preset, + static_cast(i + 1), + static_cast(bw & 0xff), + static_cast(bw >> 8), + static_cast(freq & 0xff), + static_cast(freq >> 8), + }; + auto reply = sendRequest(device_handle, CMD_SET_EQ_BAND, payload, /*expect_payload=*/false); + if (!reply) { + return reply.error(); + } + if ((*reply)[1] == STATUS_OK && (*reply)[PAYLOAD_OFFSET] != CMD_SET_EQ_BAND) { + return DeviceError::protocolError("ASTRO A50 Gen 4: EQ band write was not echoed back"); + } + } + + std::array gains {}; + for (int i = 0; i < EQ_BANDS; ++i) { + gains[i] = settings.bands[i].gain; + } + if (auto result = setEqGains(device_handle, device_preset, gains); !result) { + return result.error(); + } + + return ParametricEqualizerResult {}; + } + +private: + mutable std::mutex request_mutex_; + mutable std::unordered_set pending_replies_; + + [[nodiscard]] static bool validGain(float gain) + { + return std::isfinite(gain) && gain >= EQ_GAIN_MIN_DB && gain <= EQ_GAIN_MAX_DB; + } + + // Both EQ entry points validate all gains before any I/O. Only gains are changed here. + [[nodiscard]] Result setEqGains(hid_device* device_handle, uint8_t preset, std::span gains) const + { + std::array payload { preset }; + for (int i = 0; i < EQ_BANDS; ++i) { + payload[1 + i] = gainToByte(gains[i]); + } + auto reply = sendRequest(device_handle, CMD_SET_EQ_GAIN, payload, /*expect_payload=*/false); + if (!reply) { + return reply.error(); + } + if ((*reply)[1] == STATUS_OK + && ((*reply)[PAYLOAD_OFFSET] != CMD_SET_EQ_GAIN || (*reply)[PAYLOAD_OFFSET + 1] != preset)) { + return DeviceError::protocolError("ASTRO A50 Gen 4: EQ gain write was not echoed back"); + } + return {}; + } + + // Query 0x6C and return the active preset as numbered on the wire (1..3). + [[nodiscard]] Result readActivePreset(hid_device* device_handle) const + { + auto reply = sendRequest(device_handle, CMD_GET_EQ_PRESET, {}, /*expect_payload=*/true); + if (!reply) { + return reply.error(); + } + const uint8_t preset = (*reply)[PAYLOAD_OFFSET]; + if (preset < 1 || preset > EQ_PRESETS) { + return DeviceError::protocolError("ASTRO A50 Gen 4: active preset out of range"); + } + return preset; + } + + // Write one slider's active value. Reply payload: 62 . + [[nodiscard]] Result setSlider(hid_device* device_handle, uint8_t slider, uint8_t value) const + { + const std::array payload { slider, value }; + auto reply = sendRequest(device_handle, CMD_SET_SLIDER, payload, /*expect_payload=*/false); + if (!reply) { + return reply.error(); + } + if ((*reply)[1] == STATUS_OK && (*reply)[PAYLOAD_OFFSET + 1] != slider) { + return DeviceError::protocolError("ASTRO A50 Gen 4: slider write was not echoed back"); + } + return {}; + } + + // Query 0x54 and return whether the base has a live link to the headset. + [[nodiscard]] Result queryLinked(hid_device* device_handle) const + { + auto reply = sendRequest(device_handle, CMD_HEADSET_STATUS, {}, /*expect_payload=*/true); + if (!reply) { + return reply.error(); + } + return isLinked((*reply)[PAYLOAD_OFFSET]); + } + + /** + * @brief Build a frame, write it, read the single reply and validate its status. + * + * @param expect_payload When true, a STATUS_ACCEPTED reply (no data) is an error. + * @return The full 64-byte reply; payload starts at PAYLOAD_OFFSET. + */ + [[nodiscard]] Result> sendRequest( + hid_device* device_handle, + uint8_t cmd, + std::span payload, + bool expect_payload) const + { + const std::lock_guard lock(request_mutex_); + if (pending_replies_.contains(device_handle)) { + // Several reply layouts carry no command identifier, so give the outstanding + // reply one bounded chance to arrive and discard it. Proceed either way: a reply + // that never comes (lost, or a handle address reused without onConnectionClosed()) + // must not block the connection forever. Every supported command answers within + // milliseconds; a later frame would be read by the next request, which the + // per-command echo checks catch for the setters. + constexpr int RECOVERY_TIMEOUT_MS = 1000; + std::array stale {}; + static_cast(readHIDTimeout(device_handle, stale, RECOVERY_TIMEOUT_MS)); + pending_replies_.erase(device_handle); + } + + const auto frame = buildFrame(cmd, payload); + if (auto w = writeHID(device_handle, frame, FRAME_SIZE); !w) { + return w.error(); + } + + std::array reply {}; + auto read_result = readHIDTimeout(device_handle, reply, hsc_device_timeout); + if (!read_result) { + pending_replies_.insert(device_handle); + return read_result.error(); + } + if (*read_result == 0) { + pending_replies_.insert(device_handle); + return DeviceError::timeout("ASTRO A50 Gen 4: no reply from base station"); + } + + if (*read_result != FRAME_SIZE) { + return DeviceError::protocolError("ASTRO A50 Gen 4: incomplete reply from base station"); + } + if (reply[0] != REPORT_ID) { + return DeviceError::protocolError("ASTRO A50 Gen 4: reply does not start with report ID 0x02"); + } + + switch (reply[1]) { + case STATUS_OK: + return reply; + case STATUS_ACCEPTED: + if (expect_payload) { + return DeviceError::protocolError("ASTRO A50 Gen 4: base station returned no data"); + } + return reply; + case STATUS_ERROR: + return DeviceError::protocolError( + std::format("ASTRO A50 Gen 4: {} (code 0x{:02x})", errorName(reply), reply[PAYLOAD_OFFSET])); + default: + return DeviceError::protocolError("ASTRO A50 Gen 4: unknown reply status"); + } + } +}; + +} // namespace headsetcontrol diff --git a/lib/headsetcontrol.cpp b/lib/headsetcontrol.cpp index f1eab1b..46a9d1b 100644 --- a/lib/headsetcontrol.cpp +++ b/lib/headsetcontrol.cpp @@ -150,6 +150,9 @@ class HeadsetImpl { { for (auto& [key, handle] : connections_) { if (handle) { + if (device_) { + device_->onConnectionClosed(handle); + } hid_close(handle); } } diff --git a/tests/test_protocols.cpp b/tests/test_protocols.cpp index e23b8b3..cc72273 100644 --- a/tests/test_protocols.cpp +++ b/tests/test_protocols.cpp @@ -11,6 +11,7 @@ #include "device.hpp" #include "devices/corsair_device.hpp" +#include "devices/logitech_astro_a50_gen4.hpp" #include "devices/logitech_gpro_x2_lightspeed.hpp" #include "devices/plantronics_bt600.hpp" #include "devices/protocols/hidpp_protocol.hpp" @@ -20,10 +21,16 @@ #include "result_types.hpp" #include "utility.hpp" +#include #include +#include +#include #include +#include #include #include +#include +#include namespace headsetcontrol::testing { @@ -703,6 +710,624 @@ void testCorsairPacketFormat() std::cout << " [OK] Corsair packet format verified" << std::endl; } +// ============================================================================ +// Logitech ASTRO A50 Gen 4 Tests +// ============================================================================ + +/** + * @brief Scripted HID interface: records writes, serves queued replies in order. + * + * An empty queue returns a timeout error, matching RealHIDInterface. Explicit empty + * replies also allow testing the zero-byte result handled by the protocol. + */ +class ScriptedHIDInterface : public HIDInterface { +public: + std::deque>> replies; + std::vector read_timeouts; + std::vector writes_at_read; + std::vector> writes; + + [[nodiscard]] auto write(hid_device* /*device_handle*/, std::span data) + -> Result override + { + writes.emplace_back(data.begin(), data.end()); + return {}; + } + + [[nodiscard]] auto write(hid_device* /*device_handle*/, std::span data, size_t size) + -> Result override + { + std::vector padded(size, 0); + std::copy_n(data.begin(), std::min(data.size(), size), padded.begin()); + writes.push_back(std::move(padded)); + return {}; + } + + [[nodiscard]] auto readTimeout(hid_device* /*device_handle*/, std::span data, int timeout_ms) + -> Result override + { + read_timeouts.push_back(timeout_ms); + writes_at_read.push_back(writes.size()); + if (replies.empty()) { + return DeviceError::timeout("Scripted read timeout"); + } + auto reply = std::move(replies.front()); + replies.pop_front(); + if (!reply) { + return reply.error(); + } + const size_t n = std::min(reply->size(), data.size()); + std::copy_n(reply->begin(), n, data.begin()); + return n; + } + + [[nodiscard]] auto sendFeatureReport(hid_device* /*device_handle*/, std::span /*data*/) + -> Result override + { + return {}; + } + + [[nodiscard]] auto sendFeatureReport(hid_device* /*device_handle*/, std::span /*data*/, size_t /*size*/) + -> Result override + { + return {}; + } + + [[nodiscard]] auto getFeatureReport(hid_device* /*device_handle*/, std::span /*data*/) + -> Result override + { + return size_t { 0 }; + } + + [[nodiscard]] auto getInputReport(hid_device* /*device_handle*/, std::span /*data*/) + -> Result override + { + return size_t { 0 }; + } +}; + +class TestableAstroA50Gen4 : public LogitechAstroA50Gen4 { +public: + mutable ScriptedHIDInterface hid; + + [[nodiscard]] auto getHIDInterface() const -> HIDInterface& override { return hid; } +}; + +/// Build a 64-byte base-station reply: 02 STATUS LEN PAYLOAD... +static std::vector a50Reply(uint8_t status, std::initializer_list payload) +{ + std::vector reply(LogitechAstroA50Gen4::FRAME_SIZE, 0); + reply[0] = LogitechAstroA50Gen4::REPORT_ID; + reply[1] = status; + reply[2] = static_cast(payload.size()); + std::copy(payload.begin(), payload.end(), reply.begin() + LogitechAstroA50Gen4::PAYLOAD_OFFSET); + return reply; +} + +void testAstroA50Gen4FrameBuilding() +{ + std::cout << " Testing ASTRO A50 Gen 4 frame building..." << std::endl; + + auto bare = LogitechAstroA50Gen4::buildFrame(LogitechAstroA50Gen4::CMD_GET_BATTERY, {}); + ASSERT_EQ(64, static_cast(bare.size()), "Frame should be 64 bytes"); + ASSERT_EQ(0x02, bare[0], "Frame should start with report ID 0x02"); + ASSERT_EQ(0x7C, bare[1], "Command should be at byte 1"); + ASSERT_EQ(0x00, bare[2], "A request without payload has no length byte"); + + std::array payload { 0x05, 0x64 }; + auto set = LogitechAstroA50Gen4::buildFrame(LogitechAstroA50Gen4::CMD_SET_SLIDER, payload); + ASSERT_EQ(0x62, set[1], "Command should be at byte 1"); + ASSERT_EQ(0x02, set[2], "Length byte should count only the payload"); + ASSERT_EQ(0x05, set[3], "Payload should start at byte 3"); + ASSERT_EQ(0x64, set[4], "Payload bytes should be copied unchanged"); + ASSERT_EQ(0x00, set[5], "Frame should be zero-padded after the payload"); + + std::cout << " [OK] ASTRO A50 Gen 4 frame building verified" << std::endl; +} + +void testAstroA50Gen4Decoding() +{ + std::cout << " Testing ASTRO A50 Gen 4 value decoding..." << std::endl; + + using Dev = LogitechAstroA50Gen4; + ASSERT_EQ(97, Dev::batteryPercent(0xe1), "Bits 0-6 are the percentage"); + ASSERT_TRUE(Dev::batteryCharging(0xe1), "Bit 7 set means charging"); + ASSERT_EQ(100, Dev::batteryPercent(0x64), "100% decodes unchanged"); + ASSERT_TRUE(!Dev::batteryCharging(0x64), "Bit 7 clear means not charging"); + + ASSERT_TRUE(Dev::isLinked(0x02), "Bit 1 set: linked, undocked"); + ASSERT_TRUE(Dev::isLinked(0x03), "Bit 1 set: linked, docked"); + ASSERT_TRUE(!Dev::isLinked(0x00), "No bits: headset off, no link"); + ASSERT_TRUE(!Dev::isLinked(0x01), "Docked without link is not linked"); + + ASSERT_EQ(0, Dev::balanceToLevel(255), "255 is full game -> level 0"); + ASSERT_EQ(128, Dev::balanceToLevel(0), "0 is full voice -> level 128"); + ASSERT_EQ(64, Dev::balanceToLevel(130), "Physical midpoint (130) reports as balanced"); + ASSERT_EQ(64, Dev::balanceToLevel(125), "One grid step below centre also reports as balanced"); + ASSERT_EQ(28, Dev::balanceToLevel(200), "Game-leaning value maps below 64"); + ASSERT_EQ(101, Dev::balanceToLevel(55), "Voice-leaning value maps above 64"); + + auto err = a50Reply(Dev::STATUS_ERROR, { 0x11, 0x00, 0x00, 0x00, 'H', 'I', 'D', '_', 'E', 'R', 'R', 0x00 }); + std::array err_frame {}; + std::copy(err.begin(), err.end(), err_frame.begin()); + ASSERT_EQ(std::string("HID_ERR"), Dev::errorName(err_frame), "Error name is NUL-terminated ASCII at payload offset 4"); + + std::cout << " [OK] ASTRO A50 Gen 4 value decoding verified" << std::endl; +} + +void testAstroA50Gen4LinkGating() +{ + std::cout << " Testing ASTRO A50 Gen 4 link gating..." << std::endl; + + using Dev = LogitechAstroA50Gen4; + TestableAstroA50Gen4 dev; + + // Headset off: battery must not be read at all (the base would serve a stale value). + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x00 })); + auto battery = dev.getBattery(nullptr); + ASSERT_TRUE(battery.hasValue(), "Unlinked headset is a result, not an error"); + ASSERT_EQ(static_cast(BATTERY_UNAVAILABLE), static_cast(battery->status), "Unlinked headset reports BATTERY_UNAVAILABLE"); + ASSERT_EQ(-1, battery->level_percent, "Unlinked headset reports level -1"); + ASSERT_EQ(1, static_cast(dev.hid.writes.size()), "Only the status query should be sent"); + ASSERT_EQ(0x54, dev.hid.writes[0][1], "Status query is command 0x54"); + + // Headset linked: status query then battery query. + dev.hid.writes.clear(); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x02 })); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0xe1 })); + battery = dev.getBattery(nullptr); + ASSERT_TRUE(battery.hasValue(), "Linked battery read should succeed"); + ASSERT_EQ(97, battery->level_percent, "Battery percent decoded from bits 0-6"); + ASSERT_EQ(static_cast(BATTERY_CHARGING), static_cast(battery->status), "Bit 7 means charging"); + ASSERT_EQ(2, static_cast(dev.hid.writes.size()), "Status query followed by battery query"); + ASSERT_EQ(0x7C, dev.hid.writes[1][1], "Battery query is command 0x7C"); + ASSERT_EQ(64, static_cast(dev.hid.writes[1].size()), "Every write is a full 64-byte frame"); + + // Chatmix has no 'unavailable' state, so an unlinked headset is an error. + dev.hid.writes.clear(); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x00 })); + auto chatmix = dev.getChatmix(nullptr); + ASSERT_TRUE(chatmix.hasError(), "Unlinked chatmix read must fail rather than return 0xfd"); + ASSERT_EQ(1, static_cast(dev.hid.writes.size()), "Balance must not be queried without a link"); + + std::cout << " [OK] ASTRO A50 Gen 4 link gating verified" << std::endl; +} + +void testAstroA50Gen4Chatmix() +{ + std::cout << " Testing ASTRO A50 Gen 4 chatmix..." << std::endl; + + using Dev = LogitechAstroA50Gen4; + TestableAstroA50Gen4 dev; + + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x02 })); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0xff })); + auto full_game = dev.getChatmix(nullptr); + ASSERT_TRUE(full_game.hasValue(), "Chatmix read should succeed"); + ASSERT_EQ(0, full_game->level, "Raw 255 is full game -> level 0"); + ASSERT_EQ(100, full_game->game_volume_percent, "Full game: game 100%"); + ASSERT_EQ(0, full_game->chat_volume_percent, "Full game: chat 0%"); + ASSERT_EQ(0x72, dev.hid.writes[1][1], "Balance query is command 0x72"); + + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x02 })); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x82 })); + auto centre = dev.getChatmix(nullptr); + ASSERT_TRUE(centre.hasValue(), "Chatmix read should succeed"); + ASSERT_EQ(64, centre->level, "Physical midpoint reports level 64"); + ASSERT_EQ(100, centre->game_volume_percent, "Balanced: game 100%"); + ASSERT_EQ(100, centre->chat_volume_percent, "Balanced: chat 100%"); + + std::cout << " [OK] ASTRO A50 Gen 4 chatmix verified" << std::endl; +} + +void testAstroA50Gen4Setters() +{ + std::cout << " Testing ASTRO A50 Gen 4 sliders and noise gate..." << std::endl; + + using Dev = LogitechAstroA50Gen4; + TestableAstroA50Gen4 dev; + + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x62, 0x05 })); + auto sidetone = dev.setSidetone(nullptr, 128); + ASSERT_TRUE(sidetone.hasValue(), "Sidetone write should succeed"); + ASSERT_EQ(0x62, dev.hid.writes[0][1], "Slider write is command 0x62"); + ASSERT_EQ(0x02, dev.hid.writes[0][2], "Slider write carries a 2-byte payload"); + ASSERT_EQ(0x05, dev.hid.writes[0][3], "Sidetone is slider 0x05"); + ASSERT_EQ(100, dev.hid.writes[0][4], "Level 128 maps to device 100"); + ASSERT_EQ(100, sidetone->device_level, "Result reports the device-native level"); + + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x62, 0x05 })); + sidetone = dev.setSidetone(nullptr, 64); + ASSERT_TRUE(sidetone.hasValue(), "Sidetone write should succeed"); + ASSERT_EQ(50, dev.hid.writes[1][4], "Level 64 maps to device 50"); + + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x62, 0x04 })); + auto mic = dev.setMicVolume(nullptr, 128); + ASSERT_TRUE(mic.hasValue(), "Mic volume write should succeed"); + ASSERT_EQ(0x04, dev.hid.writes[2][3], "Microphone is slider 0x04"); + ASSERT_EQ(100, dev.hid.writes[2][4], "Volume 128 maps to device 100"); + + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x68, 0x05, 50, 50 })); + auto read_back = dev.getSidetone(nullptr); + ASSERT_TRUE(read_back.hasValue(), "Sidetone read should succeed"); + ASSERT_EQ(0x68, dev.hid.writes[3][1], "Slider read is command 0x68"); + ASSERT_EQ(0x05, dev.hid.writes[3][3], "Slider read names the sidetone slider"); + ASSERT_EQ(50, read_back->device_level, "Active slider value is payload[2]"); + ASSERT_EQ(64, read_back->current_level, "Device 50 maps back to level 64"); + + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x03 })); + auto noise = dev.setNoiseFilter(nullptr, 2); + ASSERT_TRUE(noise.hasValue(), "Noise filter write should succeed"); + ASSERT_EQ(0x64, dev.hid.writes[4][1], "Noise gate write is command 0x64"); + ASSERT_EQ(0x03, dev.hid.writes[4][3], "HSC level 2 maps to Tournament (0x03)"); + + const auto writes_before = dev.hid.writes.size(); + auto bad = dev.setNoiseFilter(nullptr, 3); + ASSERT_TRUE(bad.hasError(), "Noise filter level 3 is rejected"); + ASSERT_EQ(static_cast(writes_before), static_cast(dev.hid.writes.size()), "Rejected level must not reach the device"); + + std::cout << " [OK] ASTRO A50 Gen 4 sliders and noise gate verified" << std::endl; +} + +void testAstroA50Gen4ErrorHandling() +{ + std::cout << " Testing ASTRO A50 Gen 4 error handling..." << std::endl; + + using Dev = LogitechAstroA50Gen4; + TestableAstroA50Gen4 dev; + + dev.hid.replies.push_back(a50Reply(Dev::STATUS_ERROR, + { 0x11, 0x00, 0x00, 0x00, 'H', 'I', 'D', '_', 'E', 'R', 'R', 'O', 'R', '_', 'V', 'A', 'L', 'U', 'E', '_', + 'O', 'U', 'T', '_', 'O', 'F', '_', 'R', 'A', 'N', 'G', 'E', 0x00 })); + auto err = dev.setSidetone(nullptr, 64); + ASSERT_TRUE(err.hasError(), "Status 0x01 is an error"); + const std::string text = err.error().message + " " + err.error().details; + ASSERT_TRUE(text.find("HID_ERROR_VALUE_OUT_OF_RANGE") != std::string::npos, "Error surfaces the device's ASCII name"); + ASSERT_TRUE(text.find("0x11") != std::string::npos, "Error surfaces the device's error code"); + + // No reply at all: timeout, not a stale parse. + auto timeout = dev.getSidetone(nullptr); + ASSERT_TRUE(timeout.hasError(), "Missing reply is an error"); + ASSERT_TRUE(timeout.error().code == DeviceError::Code::Timeout, "Missing reply is reported as a timeout"); + + // Model closing the timed-out connection before testing independent protocol errors. + dev.onConnectionClosed(nullptr); + + // Accepted-without-data on a query is a protocol error, not a zero reading. + dev.hid.replies.push_back(a50Reply(Dev::STATUS_ACCEPTED, {})); + auto empty = dev.getSidetone(nullptr); + ASSERT_TRUE(empty.hasError(), "Status 0x00 on a query is an error"); + + // Wrong report ID is rejected. + auto bogus = a50Reply(Dev::STATUS_OK, { 0x68, 0x05, 50, 50 }); + bogus[0] = 0x00; + dev.hid.replies.push_back(bogus); + auto wrong_id = dev.getSidetone(nullptr); + ASSERT_TRUE(wrong_id.hasError(), "Reply without report ID 0x02 is rejected"); + + std::cout << " [OK] ASTRO A50 Gen 4 error handling verified" << std::endl; +} + +void testAstroA50Gen4Equalizer() +{ + std::cout << " Testing ASTRO A50 Gen 4 equalizer..." << std::endl; + + using Dev = LogitechAstroA50Gen4; + + // Encoding helpers + ASSERT_EQ(0x11, Dev::gainToByte(5.0f), "+5 dB encodes as 17"); + ASSERT_EQ(0x0c, Dev::gainToByte(0.0f), "0 dB encodes as 12"); + ASSERT_EQ(0x05, Dev::gainToByte(-7.0f), "-7 dB encodes as 5"); + ASSERT_EQ(4096, Dev::qToBandwidth(1.0f), "Q 1.0 is one octave-equivalent: 4096"); + ASSERT_EQ(8192, Dev::qToBandwidth(0.5f), "Q 0.5 doubles the bandwidth"); + ASSERT_EQ(Dev::EQ_BW_MIN, Dev::qToBandwidth(100.0f), "Very narrow Q clamps to the device minimum"); + ASSERT_EQ(Dev::EQ_BW_MAX, Dev::qToBandwidth(0.01f), "Very wide Q clamps to the device maximum"); + + // Preset: acknowledged, then read-back lags one poll before agreeing. + { + TestableAstroA50Gen4 dev; + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x67, 0x02 })); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x01 })); // stale + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x02 })); // settled + auto preset = dev.setEqualizerPreset(nullptr, 1); + ASSERT_TRUE(preset.hasValue(), "Preset write succeeds once the read-back agrees"); + ASSERT_EQ(1, preset->preset, "Result reports the HeadsetControl preset index"); + ASSERT_EQ(3, preset->total_presets, "Three presets"); + ASSERT_EQ(3, static_cast(dev.hid.writes.size()), "One write plus two polls"); + ASSERT_EQ(0x67, dev.hid.writes[0][1], "Preset write is command 0x67"); + ASSERT_EQ(0x02, dev.hid.writes[0][3], "HeadsetControl preset 1 is device preset 2"); + ASSERT_EQ(0x6C, dev.hid.writes[1][1], "Read-back polls command 0x6C"); + } + + // Preset: read-back never agrees. + { + TestableAstroA50Gen4 dev; + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x67, 0x03 })); + for (int i = 0; i < Dev::EQ_PRESET_POLL_LIMIT; ++i) { + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x01 })); + } + auto stuck = dev.setEqualizerPreset(nullptr, 2); + ASSERT_TRUE(stuck.hasError(), "Preset write that never takes effect is an error"); + ASSERT_EQ(1 + Dev::EQ_PRESET_POLL_LIMIT, static_cast(dev.hid.writes.size()), "Polling is bounded"); + + const auto writes_before = dev.hid.writes.size(); + auto bad = dev.setEqualizerPreset(nullptr, 3); + ASSERT_TRUE(bad.hasError(), "Preset 3 is rejected"); + ASSERT_EQ(static_cast(writes_before), static_cast(dev.hid.writes.size()), "Rejected preset must not reach the device"); + } + + // Parametric EQ: active preset read, five band writes, one gain write. + { + TestableAstroA50Gen4 dev; + ParametricEqualizerSettings settings; + settings.bands = { + { .frequency = 100.0f, .gain = 5.0f, .q_factor = 1.0f, .type = EqualizerFilterType::LowShelf }, + { .frequency = 400.0f, .gain = -4.0f, .q_factor = 1.0f, .type = EqualizerFilterType::Peaking }, + { .frequency = 1000.0f, .gain = 0.0f, .q_factor = 0.5f, .type = EqualizerFilterType::Peaking }, + { .frequency = 4000.0f, .gain = 7.0f, .q_factor = 2.0f, .type = EqualizerFilterType::Peaking }, + { .frequency = 10000.0f, .gain = -7.0f, .q_factor = 1.0f, .type = EqualizerFilterType::HighShelf }, + }; + + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x03 })); // active preset 3 + for (int i = 0; i < Dev::EQ_BANDS; ++i) { + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x6F, 0x03, static_cast(i + 1), 0x00 })); + } + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x63, 0x03 })); + + auto peq = dev.setParametricEqualizer(nullptr, settings); + ASSERT_TRUE(peq.hasValue(), "Parametric EQ write succeeds"); + ASSERT_EQ(7, static_cast(dev.hid.writes.size()), "Preset read + 5 bands + gains"); + ASSERT_EQ(0x6C, dev.hid.writes[0][1], "First the active preset is read"); + + // Band 1: low shelf -> bandwidth 0, frequency 100 (0x0064) + ASSERT_EQ(0x6F, dev.hid.writes[1][1], "Band write is command 0x6F"); + ASSERT_EQ(0x06, dev.hid.writes[1][2], "Band write carries a 6-byte payload"); + ASSERT_EQ(0x03, dev.hid.writes[1][3], "Band write targets the active preset"); + ASSERT_EQ(0x01, dev.hid.writes[1][4], "Bands are numbered from 1"); + ASSERT_EQ(0x00, dev.hid.writes[1][5], "Shelf bandwidth low byte is 0"); + ASSERT_EQ(0x00, dev.hid.writes[1][6], "Shelf bandwidth high byte is 0"); + ASSERT_EQ(0x64, dev.hid.writes[1][7], "Frequency low byte (little-endian)"); + ASSERT_EQ(0x00, dev.hid.writes[1][8], "Frequency high byte"); + + // Band 3: Q 0.5 -> bandwidth 8192 (0x2000), frequency 1000 (0x03e8) + ASSERT_EQ(0x00, dev.hid.writes[3][5], "Bandwidth 8192 low byte"); + ASSERT_EQ(0x20, dev.hid.writes[3][6], "Bandwidth 8192 high byte"); + ASSERT_EQ(0xe8, dev.hid.writes[3][7], "Frequency 1000 low byte"); + ASSERT_EQ(0x03, dev.hid.writes[3][8], "Frequency 1000 high byte"); + + // Gains: 63 <5 gains> + ASSERT_EQ(0x63, dev.hid.writes[6][1], "Gain write is command 0x63"); + ASSERT_EQ(0x06, dev.hid.writes[6][2], "Gain write carries preset + 5 gains"); + ASSERT_EQ(0x03, dev.hid.writes[6][3], "Gain write targets the active preset"); + ASSERT_EQ(0x11, dev.hid.writes[6][4], "+5 dB"); + ASSERT_EQ(0x08, dev.hid.writes[6][5], "-4 dB"); + ASSERT_EQ(0x0c, dev.hid.writes[6][6], "0 dB"); + ASSERT_EQ(0x13, dev.hid.writes[6][7], "+7 dB"); + ASSERT_EQ(0x05, dev.hid.writes[6][8], "-7 dB"); + + // Validation happens before anything is sent. + const auto writes_before = dev.hid.writes.size(); + auto four_bands = settings; + four_bands.bands.pop_back(); + ASSERT_TRUE(dev.setParametricEqualizer(nullptr, four_bands).hasError(), "Four bands are rejected"); + + auto wrong_type = settings; + wrong_type.bands[0].type = EqualizerFilterType::Peaking; + ASSERT_TRUE(dev.setParametricEqualizer(nullptr, wrong_type).hasError(), "Band 1 must be a low shelf"); + + auto loud = settings; + loud.bands[2].gain = 8.0f; + ASSERT_TRUE(dev.setParametricEqualizer(nullptr, loud).hasError(), "+8 dB is rejected"); + + auto low = settings; + low.bands[1].frequency = 50.0f; + ASSERT_TRUE(dev.setParametricEqualizer(nullptr, low).hasError(), "50 Hz is rejected"); + + auto narrow = settings; + narrow.bands[1].q_factor = 20.0f; + ASSERT_TRUE(dev.setParametricEqualizer(nullptr, narrow).hasError(), "Q 20 is rejected rather than clamped"); + + auto wide = settings; + wide.bands[1].q_factor = 0.2f; + ASSERT_TRUE(dev.setParametricEqualizer(nullptr, wide).hasError(), "Q 0.2 is rejected rather than clamped"); + ASSERT_EQ(static_cast(writes_before), static_cast(dev.hid.writes.size()), "Rejected settings must not reach the device"); + } + + std::cout << " [OK] ASTRO A50 Gen 4 equalizer verified" << std::endl; +} + +void testAstroA50Gen4BasicEqualizer() +{ + using Dev = LogitechAstroA50Gen4; + TestableAstroA50Gen4 dev; + ASSERT_TRUE(dev.getCapabilities() & B(CAP_EQUALIZER), "Basic EQ is advertised"); + const auto info = dev.getEqualizerInfo(); + ASSERT_TRUE(info.has_value(), "Basic EQ metadata is available"); + ASSERT_EQ(5, info->bands_count, "Five gains"); + ASSERT_EQ(-7, info->bands_min, "Minimum gain"); + ASSERT_EQ(7, info->bands_max, "Maximum gain"); + ASSERT_EQ(1.0f, info->bands_step, "One dB steps"); + ASSERT_EQ(0, info->bands_baseline, "Flat baseline"); + + const EqualizerSettings settings({ -7, -4, 0, 5, 7 }); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 2 })); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x63, 2 })); + ASSERT_TRUE(dev.setEqualizer(nullptr, settings).hasValue(), "Basic EQ succeeds"); + ASSERT_EQ(2u, dev.hid.writes.size(), "Only active preset read and gain write; no band writes"); + ASSERT_EQ(0x6c, dev.hid.writes[0][1], "Read active preset"); + const auto& frame = dev.hid.writes[1]; + ASSERT_EQ(64u, frame.size(), "Full report"); + ASSERT_EQ(0x63, frame[1], "Set gains"); + ASSERT_EQ(6, frame[2], "Preset and five gains"); + ASSERT_EQ(2, frame[3], "Target active preset"); + const std::array expected { 5, 8, 12, 17, 19 }; + ASSERT_TRUE(std::equal(expected.begin(), expected.end(), frame.begin() + 4), "Encode all five gains"); + ASSERT_TRUE(std::all_of(frame.begin() + 9, frame.end(), [](auto b) { return b == 0; }), "Zero padding"); + + const auto count = dev.hid.writes.size(); + ASSERT_TRUE(dev.setEqualizer(nullptr, EqualizerSettings({ 0, 0, 0, 0 })).hasError(), "Reject wrong band count"); + for (float gain : { -8.0f, 8.0f, std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), -std::numeric_limits::infinity() }) { + auto bad = settings; + bad.bands[4] = gain; + auto result = dev.setEqualizer(nullptr, bad); + ASSERT_TRUE(result.hasError() && result.error().code == DeviceError::Code::InvalidParameter, "Reject invalid gain"); + } + ASSERT_EQ(count, dev.hid.writes.size(), "Validate every gain before any I/O"); + + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0 })); + ASSERT_TRUE(dev.setEqualizer(nullptr, settings).hasError(), "Reject invalid active preset"); + ASSERT_EQ(count + 1, dev.hid.writes.size(), "Do not write gains to invalid preset"); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 2 })); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x63, 1 })); + ASSERT_TRUE(dev.setEqualizer(nullptr, settings).hasError(), "Reject gain ack for another preset"); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 2 })); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_ERROR, { 0x11 })); + ASSERT_TRUE(dev.setEqualizer(nullptr, settings).hasError(), "Propagate gain write rejection"); +} + +void testAstroA50Gen4NonfiniteEqualizer() +{ + ParametricEqualizerSettings settings; + for (int i = 0; i < LogitechAstroA50Gen4::EQ_BANDS; ++i) { + settings.bands.push_back({ .frequency = 1000.0f, .gain = 0.0f, .q_factor = 1.0f, .type = LogitechAstroA50Gen4::bandType(i) }); + } + TestableAstroA50Gen4 dev; + for (float value : { std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity(), + -std::numeric_limits::infinity() }) { + for (int i = 0; i < 5; ++i) { + for (auto member : { &ParametricEqualizerBand::frequency, &ParametricEqualizerBand::gain, + &ParametricEqualizerBand::q_factor }) { + // Shelf Q is not encoded or used by the device. + if ((i == 0 || i == 4) && member == &ParametricEqualizerBand::q_factor) { + continue; + } + auto bad = settings; + bad.bands[i].*member = value; + auto result = dev.setParametricEqualizer(nullptr, bad); + ASSERT_TRUE(result.hasError() && result.error().code == DeviceError::Code::InvalidParameter, + "Reject nonfinite EQ fields"); + } + } + } + ASSERT_TRUE(dev.hid.writes.empty(), "Nonfinite PEQ never reaches the device"); +} + +void testAstroA50Gen4ReplyLength() +{ + using Dev = LogitechAstroA50Gen4; + TestableAstroA50Gen4 dev; + for (size_t size = 1; size < Dev::FRAME_SIZE; ++size) { + auto ack = a50Reply(Dev::STATUS_OK, { 0x62, 5 }); + ack.resize(size); + dev.hid.replies.push_back(ack); + ASSERT_TRUE(dev.setSidetone(nullptr, 64).hasError(), "Reject every truncated setter reply"); + auto reading = a50Reply(Dev::STATUS_OK, { 0x68, 5, 50, 50 }); + reading.resize(size); + dev.hid.replies.push_back(reading); + ASSERT_TRUE(dev.getSidetone(nullptr).hasError(), "Reject every truncated getter reply"); + } + for (uint8_t length : { 0x00, 0x44, 0xff }) { + auto reply = a50Reply(Dev::STATUS_OK, { 0x68, 5, 50, 50 }); + reply[2] = length; + dev.hid.replies.push_back(reply); + auto reading = dev.getSidetone(nullptr); + ASSERT_TRUE(reading.hasValue(), "Accept full report despite bogus payload length"); + ASSERT_EQ(64, reading->current_level, "Decode active value using fixed offset"); + } +} + +void testAstroA50Gen4TimeoutRecovery() +{ + using Dev = LogitechAstroA50Gen4; + // Real HIDInterface returns an error on timeout; also cover the zero-byte variant. + for (bool zero_bytes : { false, true }) { + auto no_reply = [zero_bytes]() -> Result> { + if (zero_bytes) { + return std::vector {}; + } + return DeviceError::timeout("Scripted timeout"); + }; + + // A lost reply must not block the connection: one bounded recovery read, then proceed. + TestableAstroA50Gen4 dev; + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 2 })); + dev.hid.replies.push_back(no_reply()); + ASSERT_TRUE(dev.getBattery(nullptr).hasError(), "Battery times out"); + ASSERT_EQ(2u, dev.hid.writes.size(), "Status and battery query issued"); + + dev.hid.replies.push_back(no_reply()); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 2 })); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 130 })); + size_t reads = dev.hid.writes_at_read.size(); + auto chatmix = dev.getChatmix(nullptr); + ASSERT_TRUE(chatmix.hasValue(), "Lost reply: recovery gives up and the request proceeds"); + ASSERT_EQ(64, chatmix->level, "Fresh chatmix after a lost reply"); + ASSERT_EQ(4u, dev.hid.writes.size(), "Two new requests after recovery"); + ASSERT_EQ(2u, dev.hid.writes_at_read[reads], "Recovery read precedes the status request"); + ASSERT_TRUE(dev.hid.read_timeouts[reads] > 0 && dev.hid.read_timeouts[reads] <= 1000, "Recovery has bounded positive wait"); + ASSERT_TRUE(dev.hid.replies.empty(), "All replies consumed"); + + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 2 })); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 130 })); + reads = dev.hid.writes_at_read.size(); + ASSERT_TRUE(dev.getChatmix(nullptr).hasValue(), "Connection is synchronized again"); + ASSERT_EQ(hsc_device_timeout, dev.hid.read_timeouts[reads], "No recovery read once the pending state is cleared"); + + // A late reply arriving during recovery is discarded, not misread as link status. + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 2 })); + dev.hid.replies.push_back(no_reply()); + ASSERT_TRUE(dev.getBattery(nullptr).hasError(), "Battery times out again"); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 98 })); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 2 })); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 130 })); + chatmix = dev.getChatmix(nullptr); + ASSERT_TRUE(chatmix.hasValue(), "Recover then read fresh chatmix"); + ASSERT_EQ(64, chatmix->level, "Late battery and status must not become balance"); + ASSERT_TRUE(dev.hid.replies.empty(), "All replies consumed in correct order"); + } + { + // Handles are independent, and a reused handle address recovers without the hook. + TestableAstroA50Gen4 dev; + int token_a = 0, token_b = 0; + auto* handle_a = reinterpret_cast(&token_a); + auto* handle_b = reinterpret_cast(&token_b); + ASSERT_TRUE(dev.getSidetone(handle_a).hasError(), "First connection times out"); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x68, 5, 50, 50 })); + ASSERT_TRUE(dev.getSidetone(handle_b).hasValue(), "Other connection is not blocked"); + ASSERT_EQ(2u, dev.hid.writes.size(), "Independent connection sends its query"); + + dev.hid.replies.push_back(DeviceError::timeout("Old connection's reply never arrives")); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x68, 5, 50, 50 })); + ASSERT_TRUE(dev.getSidetone(handle_a).hasValue(), "Reused handle address recovers without onConnectionClosed()"); + ASSERT_EQ(3u, dev.hid.writes.size(), "Reused connection sends its query after one recovery read"); + + ASSERT_TRUE(dev.getSidetone(handle_a).hasError(), "Connection times out again"); + dev.onConnectionClosed(handle_a); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x68, 5, 50, 50 })); + const size_t reads = dev.hid.writes_at_read.size(); + ASSERT_TRUE(dev.getSidetone(handle_a).hasValue(), "Reopened connection works"); + ASSERT_EQ(hsc_device_timeout, dev.hid.read_timeouts[reads], "Hook skips the recovery wait"); + } + { + // Read errors and malformed frames during recovery are discarded, not propagated. + TestableAstroA50Gen4 dev; + dev.hid.replies.push_back(DeviceError::hidError("Read failed")); + ASSERT_TRUE(dev.getSidetone(nullptr).hasError(), "Read failure leaves an outstanding request"); + ASSERT_EQ(1u, dev.hid.writes.size(), "One request so far"); + dev.hid.replies.push_back(DeviceError::hidError("Still failed")); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x68, 5, 50, 50 })); + ASSERT_TRUE(dev.getSidetone(nullptr).hasValue(), "Recovery read error does not block the request"); + ASSERT_EQ(2u, dev.hid.writes.size(), "Request issued after the failed recovery read"); + + ASSERT_TRUE(dev.getSidetone(nullptr).hasError(), "Times out again"); + dev.hid.replies.push_back(std::vector { 2 }); + dev.hid.replies.push_back(a50Reply(Dev::STATUS_OK, { 0x68, 5, 50, 50 })); + ASSERT_TRUE(dev.getSidetone(nullptr).hasValue(), "Truncated recovery frame is discarded"); + ASSERT_TRUE(dev.hid.replies.empty(), "All replies consumed"); + } +} + // ============================================================================ // Test Runner // ============================================================================ @@ -735,6 +1360,19 @@ void runAllProtocolTests() runTest("Spline Battery Level", testSplineBatteryLevelProtocol); runTest("Round To Multiples", testRoundToMultiplesProtocol); + std::cout << "\n=== Logitech ASTRO A50 Gen 4 ===" << std::endl; + runTest("ASTRO A50 Gen 4 Frame Building", testAstroA50Gen4FrameBuilding); + runTest("ASTRO A50 Gen 4 Decoding", testAstroA50Gen4Decoding); + runTest("ASTRO A50 Gen 4 Link Gating", testAstroA50Gen4LinkGating); + runTest("ASTRO A50 Gen 4 Chatmix", testAstroA50Gen4Chatmix); + runTest("ASTRO A50 Gen 4 Setters", testAstroA50Gen4Setters); + runTest("ASTRO A50 Gen 4 Error Handling", testAstroA50Gen4ErrorHandling); + runTest("ASTRO A50 Gen 4 Equalizer", testAstroA50Gen4Equalizer); + runTest("ASTRO A50 Gen 4 Basic Equalizer", testAstroA50Gen4BasicEqualizer); + runTest("ASTRO A50 Gen 4 Nonfinite Equalizer", testAstroA50Gen4NonfiniteEqualizer); + runTest("ASTRO A50 Gen 4 Reply Length", testAstroA50Gen4ReplyLength); + runTest("ASTRO A50 Gen 4 Timeout Recovery", testAstroA50Gen4TimeoutRecovery); + std::cout << "\n=== HID++ Protocol (Logitech) ===" << std::endl; runTest("HID++ Constants", testHIDPPConstants); runTest("HID++ Packet Format", testHIDPPPacketFormat);