|
| 1 | +#pragma once |
| 2 | + |
| 3 | +#include "device_utils.hpp" |
| 4 | +#include "hid_device.hpp" |
| 5 | +#include <algorithm> |
| 6 | +#include <array> |
| 7 | +#include <cmath> |
| 8 | +#include <span> |
| 9 | +#include <string_view> |
| 10 | + |
| 11 | +using namespace std::string_view_literals; |
| 12 | + |
| 13 | +namespace headsetcontrol { |
| 14 | + |
| 15 | +/** |
| 16 | + * @brief Logitech ASTRO A50 Gen 5 base station — USB ID 046d:0b1c |
| 17 | + * |
| 18 | + * Reverse-engineered protocol (see ghub-reverse/NOTES.md). The base station |
| 19 | + * exposes a single vendor HID interface (interface 8, usage page 0xFF32) plus |
| 20 | + * eight USB Audio Class interfaces. Control rides report ID 0x02 on the vendor |
| 21 | + * interface as 64-byte interrupt reports. |
| 22 | + * |
| 23 | + * This is NOT HID++ and NOT the newer "Centurion" protocol (report 0x51, used by |
| 24 | + * the A50 X). It is a simple vendor command protocol: |
| 25 | + * |
| 26 | + * byte[0] = 0x02 report ID |
| 27 | + * byte[1] = 0x0c constant marker |
| 28 | + * byte[2] = LEN number of meaningful bytes that follow (= 3 + payload_len) |
| 29 | + * byte[3] = 0x00 |
| 30 | + * byte[4] = CMD command / property id |
| 31 | + * byte[5] = HANDLE transaction handle (request: low-nibble 0xC; push/reply: 0x00) |
| 32 | + * byte[6..] = payload (little-endian) |
| 33 | + * |
| 34 | + * A GET is a request with no payload; the device replies with the same CMD and a |
| 35 | + * value payload. The device also pushes these frames unsolicited on state change. |
| 36 | + * |
| 37 | + * Features implemented: |
| 38 | + * - Battery status (CMD 0x06) — reply byte6 = %, byte8 = dock/charging flag |
| 39 | + * - Chatmix (CMD 0x0a) — value 0..12 (0 = chat/voice, 12 = game) |
| 40 | + * - Sidetone (CMD 0x09) — payload 01 ff <level> |
| 41 | + * - Lights/dock LED (CMD 0x0f) — payload <brightness 0..100> |
| 42 | + * - Parametric EQ (CMD 0x0d) — 10 bands [freqBE16][Q LE16][gainB]; Q=scale/32, |
| 43 | + * gain byte=120+dB*20 (±6 dB). |
| 44 | + * - Noise filter (CMD 0x14) — mic noise gate; HSC 0/1/2 -> A50 Off/Night/Tournament. |
| 45 | + * |
| 46 | + * All six capabilities were verified on real hardware (A50 base 046d:0b1c): lights toggle |
| 47 | + * the dock LED, battery reads %/charging, chatmix reads 0..12 (linear), sidetone is audible, |
| 48 | + * the parametric EQ visibly shifts the sound (a ±6 dB bass boost/cut was clearly audible while |
| 49 | + * music played), and the noise gate is audible on the mic. The byte5 handle is an echo token |
| 50 | + * (the device echoes it back) — any value works, which is why the battery GET (handle 0x0c) |
| 51 | + * reads correctly even though G HUB only ever pushed it. |
| 52 | + */ |
| 53 | +class LogitechAstroA50 : public HIDDevice { |
| 54 | +public: |
| 55 | + static constexpr std::array<uint16_t, 1> PRODUCT_IDS { 0x0b1c }; |
| 56 | + |
| 57 | + // Frame structure |
| 58 | + static constexpr uint8_t REPORT_ID = 0x02; |
| 59 | + static constexpr uint8_t MARKER = 0x0c; |
| 60 | + static constexpr size_t FRAME_SIZE = 64; |
| 61 | + static constexpr int POLL_ATTEMPTS = 8; |
| 62 | + |
| 63 | + // Command ids (byte[4]) |
| 64 | + static constexpr uint8_t CMD_BATTERY = 0x06; |
| 65 | + static constexpr uint8_t CMD_SIDETONE = 0x09; |
| 66 | + static constexpr uint8_t CMD_CHATMIX = 0x0a; |
| 67 | + static constexpr uint8_t CMD_EQ = 0x0d; |
| 68 | + static constexpr uint8_t CMD_BRIGHTNESS = 0x0f; |
| 69 | + static constexpr uint8_t CMD_NOISE_GATE = 0x14; |
| 70 | + |
| 71 | + // Transaction handles (byte[5]) — observed per command in captures |
| 72 | + static constexpr uint8_t HANDLE_CHATMIX = 0x0c; |
| 73 | + static constexpr uint8_t HANDLE_SIDETONE = 0x1c; |
| 74 | + static constexpr uint8_t HANDLE_BRIGHTNESS = 0x1c; |
| 75 | + static constexpr uint8_t HANDLE_EQ = 0x2c; // observed in eq-audio.pcapng |
| 76 | + static constexpr uint8_t HANDLE_NOISE_GATE = 0x2d; // observed in a50-noisegate.pcapng |
| 77 | + static constexpr uint8_t HANDLE_BATTERY = 0x0c; // handle is an echo token (verified) |
| 78 | + |
| 79 | + // Noise gate (CMD 0x14) — device has 4 levels; HeadsetControl noise filter is 0/1/2. |
| 80 | + // Map off/low/high -> A50 Off(0x00) / Night(0x01) / Tournament(0x04). |
| 81 | + static constexpr std::array<uint8_t, 3> NOISE_LEVELS { 0x00, 0x01, 0x04 }; |
| 82 | + |
| 83 | + // Parametric EQ (CMD 0x0d) — 10 fixed standard bands. Each band on the wire is |
| 84 | + // 5 bytes: [freq BE16][Q LE16][gain B]. Q = scale/32 (0x16 = 0.6875 default). |
| 85 | + // Gain byte: 0x78 = 0 dB, 240 = +6 dB, 0 = -6 dB → byte = 120 + dB*20 (20 units/dB). |
| 86 | + static constexpr int EQ_BANDS = 10; |
| 87 | + static constexpr uint8_t EQ_GAIN_CENTER = 120; // 0x78 = 0 dB |
| 88 | + static constexpr float EQ_GAIN_UNITS_PER_DB = 20.0f; |
| 89 | + static constexpr float EQ_GAIN_MIN_DB = -6.0f; |
| 90 | + static constexpr float EQ_GAIN_MAX_DB = 6.0f; |
| 91 | + static constexpr float EQ_Q_SCALE = 32.0f; // Q = scale / 32 |
| 92 | + static constexpr float EQ_Q_MIN = 0.031f; |
| 93 | + static constexpr float EQ_Q_MAX = 7.969f; |
| 94 | + static constexpr std::array<uint16_t, EQ_BANDS> EQ_FREQS { |
| 95 | + 20, 50, 125, 250, 500, 1000, 2500, 5000, 10000, 20000 |
| 96 | + }; |
| 97 | + |
| 98 | + uint16_t getVendorId() const override { return VENDOR_LOGITECH; } |
| 99 | + |
| 100 | + std::vector<uint16_t> getProductIds() const override |
| 101 | + { |
| 102 | + return { PRODUCT_IDS.begin(), PRODUCT_IDS.end() }; |
| 103 | + } |
| 104 | + |
| 105 | + std::string_view getDeviceName() const override { return "Logitech ASTRO A50 Gen 5"sv; } |
| 106 | + |
| 107 | + constexpr int getCapabilities() const override |
| 108 | + { |
| 109 | + return B(CAP_BATTERY_STATUS) | B(CAP_CHATMIX_STATUS) | B(CAP_SIDETONE) | B(CAP_LIGHTS) |
| 110 | + | B(CAP_PARAMETRIC_EQUALIZER) | B(CAP_NOISE_FILTER); |
| 111 | + } |
| 112 | + |
| 113 | + constexpr capability_detail getCapabilityDetail([[maybe_unused]] enum capabilities cap) const override |
| 114 | + { |
| 115 | + // Control lives on the vendor HID: interface 8 (Linux/macOS), |
| 116 | + // usage page 0xFF32 / usage 0x74 (Windows). |
| 117 | + return { .usagepage = 0xff32, .usageid = 0x0074, .interface_id = 8 }; |
| 118 | + } |
| 119 | + |
| 120 | + Result<BatteryResult> getBattery(hid_device* device_handle) override |
| 121 | + { |
| 122 | + // Reply (confirmed): 02 0c 06 00 06 00 <level%> <level2> <dock/charging> |
| 123 | + auto resp = sendRequest(device_handle, CMD_BATTERY, HANDLE_BATTERY, {}, /*read_reply=*/true); |
| 124 | + if (!resp) { |
| 125 | + return resp.error(); |
| 126 | + } |
| 127 | + |
| 128 | + const auto& f = *resp; |
| 129 | + const int level = f[6]; // byte6 = battery percent (0..100) |
| 130 | + const bool dock_chg = f[8] != 0; // byte8 = 1 when docked/charging |
| 131 | + |
| 132 | + return BatteryResult { |
| 133 | + .level_percent = level, |
| 134 | + .status = dock_chg ? BATTERY_CHARGING : BATTERY_AVAILABLE, |
| 135 | + }; |
| 136 | + } |
| 137 | + |
| 138 | + Result<ChatmixResult> getChatmix(hid_device* device_handle) override |
| 139 | + { |
| 140 | + // GET (confirmed): 02 0c 03 00 0a 0c reply: 02 0c 04 00 0a 0c <raw 0..12> |
| 141 | + auto resp = sendRequest(device_handle, CMD_CHATMIX, HANDLE_CHATMIX, {}, /*read_reply=*/true); |
| 142 | + if (!resp) { |
| 143 | + return resp.error(); |
| 144 | + } |
| 145 | + |
| 146 | + // Device raw: 0 = full chat/voice, 6 = center, 12 = full game. |
| 147 | + // HeadsetControl level: 0..128 with <64 = game, >64 = chat. |
| 148 | + const int raw = (*resp)[6]; |
| 149 | + const int level = map<int>(raw, 0, 12, 128, 0); |
| 150 | + |
| 151 | + const int game_pct = (level <= 64) ? 100 : map<int>(level, 64, 128, 100, 0); |
| 152 | + const int chat_pct = (level >= 64) ? 100 : map<int>(level, 0, 64, 0, 100); |
| 153 | + |
| 154 | + return ChatmixResult { |
| 155 | + .level = level, |
| 156 | + .game_volume_percent = game_pct, |
| 157 | + .chat_volume_percent = chat_pct, |
| 158 | + }; |
| 159 | + } |
| 160 | + |
| 161 | + Result<SidetoneResult> setSidetone(hid_device* device_handle, uint8_t level) override |
| 162 | + { |
| 163 | + // SET (captured): 02 0c 06 00 09 1c 01 ff <lvl> |
| 164 | + // Only lvl 0x00 (0%) and 0x06 (100%) were observed → assume device range 0..6. |
| 165 | + const uint8_t device_level = map<uint8_t>(level, 0, 128, 0, 6); |
| 166 | + |
| 167 | + const std::array<uint8_t, 3> payload { 0x01, 0xff, device_level }; |
| 168 | + auto r = sendRequest(device_handle, CMD_SIDETONE, HANDLE_SIDETONE, payload, /*read_reply=*/false); |
| 169 | + if (!r) { |
| 170 | + return r.error(); |
| 171 | + } |
| 172 | + |
| 173 | + return SidetoneResult { |
| 174 | + .current_level = level, |
| 175 | + .min_level = 0, |
| 176 | + .max_level = 128, |
| 177 | + .device_min = 0, |
| 178 | + .device_max = 6, |
| 179 | + }; |
| 180 | + } |
| 181 | + |
| 182 | + Result<LightsResult> setLights(hid_device* device_handle, bool on) override |
| 183 | + { |
| 184 | + // SET (captured): 02 0c 04 00 0f 1c <brightness 0..100> — dock-station LED. |
| 185 | + // CAP_LIGHTS is on/off; map to full brightness or off. |
| 186 | + const std::array<uint8_t, 1> payload { static_cast<uint8_t>(on ? 100 : 0) }; |
| 187 | + auto r = sendRequest(device_handle, CMD_BRIGHTNESS, HANDLE_BRIGHTNESS, payload, /*read_reply=*/false); |
| 188 | + if (!r) { |
| 189 | + return r.error(); |
| 190 | + } |
| 191 | + |
| 192 | + return LightsResult { |
| 193 | + .enabled = on, |
| 194 | + .mode = on ? "on" : "off", |
| 195 | + }; |
| 196 | + } |
| 197 | + |
| 198 | + Result<NoiseFilterResult> setNoiseFilter(hid_device* device_handle, uint8_t level) override |
| 199 | + { |
| 200 | + // SET (captured): 02 0c 04 00 14 2d <v> — mic noise gate. |
| 201 | + // HeadsetControl level 0/1/2 (off/low/high) -> A50 Off/Night/Tournament. |
| 202 | + if (level > 2) { |
| 203 | + return DeviceError::invalidParameter("Noise filter level must be 0, 1, or 2"); |
| 204 | + } |
| 205 | + const std::array<uint8_t, 1> payload { NOISE_LEVELS[level] }; |
| 206 | + auto r = sendRequest(device_handle, CMD_NOISE_GATE, HANDLE_NOISE_GATE, payload, /*read_reply=*/false); |
| 207 | + if (!r) { |
| 208 | + return r.error(); |
| 209 | + } |
| 210 | + |
| 211 | + return NoiseFilterResult { .level = level }; |
| 212 | + } |
| 213 | + |
| 214 | + std::optional<ParametricEqualizerInfo> getParametricEqualizerInfo() const override |
| 215 | + { |
| 216 | + return ParametricEqualizerInfo { |
| 217 | + .bands_count = EQ_BANDS, |
| 218 | + .gain_base = 0.0f, |
| 219 | + .gain_step = 1.0f / EQ_GAIN_UNITS_PER_DB, // 0.05 dB per device unit |
| 220 | + .gain_min = EQ_GAIN_MIN_DB, |
| 221 | + .gain_max = EQ_GAIN_MAX_DB, |
| 222 | + .q_factor_min = EQ_Q_MIN, |
| 223 | + .q_factor_max = EQ_Q_MAX, |
| 224 | + .freq_min = EQ_FREQS.front(), |
| 225 | + .freq_max = EQ_FREQS.back(), |
| 226 | + .filter_types = B(static_cast<int>(EqualizerFilterType::Peaking)), |
| 227 | + }; |
| 228 | + } |
| 229 | + |
| 230 | + Result<ParametricEqualizerResult> setParametricEqualizer( |
| 231 | + hid_device* device_handle, const ParametricEqualizerSettings& settings) override |
| 232 | + { |
| 233 | + // SET (decoded from eq-audio.pcapng): 02 0c 38 00 0d 2c | 01 03 00 | 10×band |
| 234 | + // header `01 03 00` = target headphone; each band = [freqBE16][Q LE16][gainB]. |
| 235 | + if (settings.size() != EQ_BANDS) { |
| 236 | + return DeviceError::invalidParameter("ASTRO A50 requires exactly 10 parametric EQ bands"); |
| 237 | + } |
| 238 | + |
| 239 | + std::array<uint8_t, 3 + EQ_BANDS * 5> payload {}; |
| 240 | + payload[0] = 0x01; // target: headphone |
| 241 | + payload[1] = 0x03; // constant (observed) |
| 242 | + payload[2] = 0x00; |
| 243 | + |
| 244 | + for (int i = 0; i < EQ_BANDS; ++i) { |
| 245 | + const auto& band = settings.bands[i]; |
| 246 | + |
| 247 | + if (band.type != EqualizerFilterType::Peaking) { |
| 248 | + return DeviceError::invalidParameter("ASTRO A50 only supports peaking EQ bands"); |
| 249 | + } |
| 250 | + if (band.frequency < EQ_FREQS.front() || band.frequency > EQ_FREQS.back()) { |
| 251 | + return DeviceError::invalidParameter("Frequency must be between 20 Hz and 20000 Hz"); |
| 252 | + } |
| 253 | + if (band.gain < EQ_GAIN_MIN_DB || band.gain > EQ_GAIN_MAX_DB) { |
| 254 | + return DeviceError::invalidParameter("Gain must be between -6 dB and +6 dB"); |
| 255 | + } |
| 256 | + if (band.q_factor < EQ_Q_MIN || band.q_factor > EQ_Q_MAX) { |
| 257 | + return DeviceError::invalidParameter("Q factor must be between 0.031 and 7.969"); |
| 258 | + } |
| 259 | + |
| 260 | + const auto freq = static_cast<uint16_t>(std::lround(band.frequency)); |
| 261 | + const auto qval = static_cast<uint16_t>( |
| 262 | + std::clamp<long>(std::lround(band.q_factor * EQ_Q_SCALE), 1, 0xffff)); |
| 263 | + const auto gainb = static_cast<uint8_t>(std::clamp<long>( |
| 264 | + std::lround(EQ_GAIN_CENTER + band.gain * EQ_GAIN_UNITS_PER_DB), 0, 240)); |
| 265 | + |
| 266 | + const size_t o = 3 + i * 5; |
| 267 | + payload[o + 0] = static_cast<uint8_t>(freq >> 8); // freq high (BE) |
| 268 | + payload[o + 1] = static_cast<uint8_t>(freq & 0xff); // freq low |
| 269 | + payload[o + 2] = static_cast<uint8_t>(qval & 0xff); // Q low (LE) |
| 270 | + payload[o + 3] = static_cast<uint8_t>(qval >> 8); // Q high |
| 271 | + payload[o + 4] = gainb; // gain |
| 272 | + } |
| 273 | + |
| 274 | + auto r = sendRequest(device_handle, CMD_EQ, HANDLE_EQ, payload, /*read_reply=*/false); |
| 275 | + if (!r) { |
| 276 | + return r.error(); |
| 277 | + } |
| 278 | + |
| 279 | + return ParametricEqualizerResult {}; |
| 280 | + } |
| 281 | + |
| 282 | +private: |
| 283 | + /** |
| 284 | + * @brief Build a vendor frame, write it, and (optionally) read the matching reply. |
| 285 | + * |
| 286 | + * @param read_reply When true, polls reads and returns the first 64-byte frame |
| 287 | + * whose marker matches and whose CMD byte equals @p cmd |
| 288 | + * (ignoring unrelated async push frames). When false, returns |
| 289 | + * an empty frame after a successful write (fire-and-forget SET). |
| 290 | + */ |
| 291 | + [[nodiscard]] Result<std::array<uint8_t, FRAME_SIZE>> sendRequest( |
| 292 | + hid_device* device_handle, |
| 293 | + uint8_t cmd, |
| 294 | + uint8_t handle_byte, |
| 295 | + std::span<const uint8_t> payload, |
| 296 | + bool read_reply) const |
| 297 | + { |
| 298 | + std::array<uint8_t, FRAME_SIZE> frame {}; |
| 299 | + frame[0] = REPORT_ID; |
| 300 | + frame[1] = MARKER; |
| 301 | + frame[2] = static_cast<uint8_t>(3 + payload.size()); // LEN counts byte[3..] |
| 302 | + frame[3] = 0x00; |
| 303 | + frame[4] = cmd; |
| 304 | + frame[5] = handle_byte; |
| 305 | + for (size_t i = 0; i < payload.size() && (6 + i) < FRAME_SIZE; ++i) { |
| 306 | + frame[6 + i] = payload[i]; |
| 307 | + } |
| 308 | + |
| 309 | + if (auto w = writeHID(device_handle, frame, FRAME_SIZE); !w) { |
| 310 | + return w.error(); |
| 311 | + } |
| 312 | + |
| 313 | + if (!read_reply) { |
| 314 | + return frame; |
| 315 | + } |
| 316 | + |
| 317 | + for (int attempt = 0; attempt < POLL_ATTEMPTS; ++attempt) { |
| 318 | + std::array<uint8_t, FRAME_SIZE> response {}; |
| 319 | + auto read_result = readHIDTimeout(device_handle, response, hsc_device_timeout); |
| 320 | + if (!read_result) { |
| 321 | + return read_result.error(); |
| 322 | + } |
| 323 | + if (*read_result == 0) { |
| 324 | + continue; // timed out with no data; retry |
| 325 | + } |
| 326 | + if (response[0] == REPORT_ID && response[1] == MARKER && response[4] == cmd) { |
| 327 | + return response; |
| 328 | + } |
| 329 | + // Otherwise it is an unrelated asynchronous push (volume, mic, BT, …); |
| 330 | + // keep polling for the frame that answers our command. |
| 331 | + } |
| 332 | + |
| 333 | + return DeviceError::timeout("ASTRO A50: no response for command"); |
| 334 | + } |
| 335 | +}; |
| 336 | + |
| 337 | +} // namespace headsetcontrol |
0 commit comments