Skip to content

Commit 1507c72

Browse files
Add Logitech ASTRO A50 Gen 5 (046d:0b1c) (#531)
* Add Logitech ASTRO A50 Gen 5 (046d:0b1c) Battery, chatmix, sidetone, lights and parametric EQ via the vendor HID protocol (report 0x02 on usage page 0xff32, interface 8). All capabilities verified on hardware. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add microphone noise filter to ASTRO A50 Gen 5 (CMD 0x14) Maps HeadsetControl noise filter 0/1/2 (off/low/high) to the A50 noise gate Off/Night/Tournament. Verified on hardware. README updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: ASTRO A50 extra features not mapped to HeadsetControl + companion GUI Lists A50 controls (master volume, mic EQ, broadcast mix, device state) that need new core capabilities (maintainer approval) and links the companion GUI project. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent da4477e commit 1507c72

4 files changed

Lines changed: 373 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ A cross-platform tool to control USB gaming headsets on **Linux**, **macOS**, an
2727
| Logitech G PRO X 2 LIGHTSPEED | All | x | x | | | x | | | | x | x | x | | | | | | |
2828
| Logitech G PRO Series | All | x | x | | | x | | | | | | | | | | | | |
2929
| Logitech Zone Wired/Zone 750 | All | x | | | | | | x | x | | | | | | | | | |
30+
| Logitech ASTRO A50 Gen 5 | All | x | x | | x | | x | | | | | x | | | | | | x |
3031
| Corsair Headset Device | All | x | x | x | x | | | | | | | | | | | | | |
3132
| Corsair Wireless V2 Headset Device | All | x | x | | | | | | | | | | | | | | | |
3233
| SteelSeries Arctis (1/7X/7P) Wireless | All | x | x | | | x | | | | | | | | | | | | |

docs/ASTRO_A50_EXTRA_FEATURES.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# ASTRO A50 Gen 5 — additional device features (not in this PR)
2+
3+
The device file `lib/devices/logitech_astro_a50.hpp` implements the A50 features that map to
4+
**existing** HeadsetControl capabilities: battery, chatmix, sidetone, lights, parametric
5+
equalizer and microphone noise filter.
6+
7+
The A50 base station exposes several other fully reverse-engineered, working controls over the
8+
same vendor HID protocol that **do not map to any existing HeadsetControl capability**. They are
9+
intentionally **left out of this PR**: adding them would require new *core* capabilities, and per
10+
the contribution guidelines that should be discussed/approved with the maintainer first rather
11+
than introduced unilaterally in a device PR.
12+
13+
| Feature | A50 command | Why it isn't here |
14+
|---------|-------------|-------------------|
15+
| Master (headset) volume | `0x08` (`02 0c 05 00 08 1b ff <0..21>`) | HeadsetControl has no output-volume capability — volume is handled OS-side |
16+
| Microphone EQ | `0x0d` with `byte6=00` (mic target) | the parametric EQ capability targets the **headphone** output only |
17+
| Stream / broadcast mix | `0x0c` (handle hi-nibble 6) | no "per-source broadcast mix" capability (porta/mic/game/BT/voice levels) |
18+
| Device state (mic/BT/online) | `0x0c` prop 2, `0x0e` | read-only state used for UI gating; not a controllable capability |
19+
20+
## Companion GUI
21+
22+
A standalone GTK tray application — **HeadsetControl-A50-GUI**
23+
(https://github.com/lluiseduardo-silva/HeadsetControlA50Gui) — implements **all** of the above
24+
plus the capabilities in this PR, by talking to the device directly over `/dev/hidrawN` and to
25+
PipeWire (for the Game/Voice split, chatmix balance and mic volume). It serves as a reference for
26+
the full protocol while these features have no upstream home.
27+
28+
If the maintainer is open to any of these becoming first-class HeadsetControl capabilities, I'm
29+
glad to contribute them upstream.

lib/device_registry.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
#include "devices/corsair_void_rich.hpp"
55
#include "devices/corsair_void_v2w.hpp"
66

7+
// Logitech devices
8+
#include "devices/logitech_astro_a50.hpp"
9+
710
// Logitech devices with HIDPPDevice protocol template
811
#include "devices/logitech_g432.hpp"
912
#include "devices/logitech_g522_lightspeed.hpp"
@@ -90,6 +93,9 @@ void DeviceRegistry::initialize()
9093
// Register all device implementations
9194
// Each device is managed by a unique_ptr for automatic cleanup
9295

96+
// Logitech devices
97+
registerDevice(std::make_unique<LogitechAstroA50>());
98+
9399
// Logitech devices (using HIDPPDevice protocol template)
94100
registerDevice(std::make_unique<LogitechG522Lightspeed>());
95101
registerDevice(std::make_unique<LogitechG533>());

lib/devices/logitech_astro_a50.hpp

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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

Comments
 (0)