This guide explains how to add support for a new USB headset to HeadsetControl.
Some headsets expose sidetone as an audio channel. Check with alsamixer first:
alsamixer
# Press F6 to select your headset
# Look for a "Sidetone" controlIf sidetone is available in ALSA, you can't implement it via HID (but you can still add battery and other features).
Your headset might be a variant of an already-supported model:
./headsetcontrol --dev -- --listIf you find a similar device (same vendor, similar product ID), you may only need to add your product ID to an existing implementation.
If your headset uses the same protocol as an existing one (common for wireless variants):
- Find the existing device file in
lib/devices/ - Add your product ID to the
PRODUCT_IDSarray - Update the array size if needed
- Rebuild and test
Note: You don't need to modify device_registry.cpp - the device is already registered there.
Example: Adding a new Corsair headset that uses the same protocol:
// In lib/devices/corsair_void_rich.hpp
static constexpr std::array<uint16_t, 5> PRODUCT_IDS {
0x1b27, 0x1b2a, 0x1b23,
0x1b2f, // <- Add your new product ID here
};For headsets with unknown protocols, continue with the full guide below.
- USB Vendor ID and Product ID of your headset
- USB capture software (Wireshark with USBPcap on Windows, or usbmon on Linux)
- The manufacturer's Windows software
- Basic C++20 knowledge
- Install Wireshark with USB capture support
- Connect your headset
- Install the manufacturer's software (Windows VM with USB passthrough works well)
- Start Wireshark, select your USB interface
- Filter by your device:
usb.idVendor == 0x1b1c && usb.idProduct == 0x1b27 - In the manufacturer's software, change a setting (e.g., sidetone)
- Stop capture and analyze the packets
Look for:
- SET_REPORT or Write packets when changing settings
- Bytes that change when you adjust values
- The packet length (must send exact byte count)
Example: When changing sidetone from 0 to 100, you might see:
Before: c9 00 00 00 00 ...
After: c9 64 00 00 00 ...
^^ This byte changed (0x64 = 100)
Use developer mode to test your findings:
# Test sending a packet
./headsetcontrol --dev -- --device 0x1b1c:0x1b27 --send-feature "0xc9, 0x64"
# Or for write (non-feature) packets
./headsetcontrol --dev -- --device 0x1b1c:0x1b27 --send "0xc9, 0x64" --receiveCreate a new file in lib/devices/ named after your headset (e.g., vendor_model.hpp).
#pragma once
#include "hid_device.hpp"
#include <array>
#include <string_view>
using namespace std::string_view_literals;
namespace headsetcontrol {
/**
* @brief Your Headset Name
*
* Features:
* - List supported features here
*/
class YourHeadset : public HIDDevice {
public:
// USB Product IDs this device supports
static constexpr std::array<uint16_t, 1> PRODUCT_IDS { 0x1234 };
uint16_t getVendorId() const override
{
return 0xABCD; // Your vendor ID
}
std::vector<uint16_t> getProductIds() const override
{
return { PRODUCT_IDS.begin(), PRODUCT_IDS.end() };
}
std::string_view getDeviceName() const override
{
return "Your Headset Name"sv;
}
int getCapabilities() const override
{
// Return bitmask of supported capabilities
return B(CAP_SIDETONE) | B(CAP_BATTERY_STATUS);
}
// Override capability details if your device needs specific HID interface/usage
constexpr capability_detail getCapabilityDetail(enum capabilities cap) const override
{
// usagepage and usageid are Windows-specific (0 = use interface)
// interface_id: which HID interface to use (0 = first enumerated)
return { .usagepage = 0, .usageid = 0, .interface_id = 3 };
}
// Implement the features your device supports...
Result<SidetoneResult> setSidetone(hid_device* device_handle, uint8_t level) override
{
// Your implementation here
}
Result<BatteryResult> getBattery(hid_device* device_handle) override
{
// Your implementation here
}
};
} // namespace headsetcontrolThe HIDDevice base class provides modern C++20 abstractions for HID communication:
// Write data to the device
std::array<uint8_t, 3> cmd { 0x00, 0x39, level };
auto result = writeHID(device_handle, cmd);
if (!result) {
return result.error(); // Propagate error
}
// Read with timeout
std::array<uint8_t, 64> buffer {};
auto read_result = readHIDTimeout(device_handle, buffer, hsc_device_timeout);
if (!read_result) {
return read_result.error();
}
size_t bytes_read = *read_result;
// Feature reports (for persistent settings)
auto feature_result = sendFeatureReport(device_handle, cmd);
auto get_result = getFeatureReport(device_handle, buffer);For common protocols, use the provided templates to reduce boilerplate:
#include "protocols/hidpp_protocol.hpp"
#include "protocols/logitech_calibrations.hpp"
class LogitechYourDevice : public protocols::HIDPPDevice<LogitechYourDevice> {
// HIDPPDevice provides sendHIDPPCommand(), requestBatteryHIDPP(), etc.
Result<BatteryResult> getBattery(hid_device* device_handle) override
{
// Use existing calibration or create your own in logitech_calibrations.hpp
return requestBatteryHIDPP(device_handle, {0x08, 0x0a}, calibrations::DEFAULT_LOGITECH);
}
};#include "protocols/steelseries_protocol.hpp"
// For Nova series (Nova 3, Nova 5, Nova 7, Nova Pro)
class SteelSeriesYourDevice : public protocols::SteelSeriesNovaDevice<SteelSeriesYourDevice> {
// Provides sendCommand(), getBatteryStatus(), getChatmix(), etc.
};
// For older devices (Arctis 1, 7, 9, Pro Wireless)
class SteelSeriesYourDevice : public protocols::SteelSeriesLegacyDevice {
// Provides basic sendCommand(), getBattery(), etc.
};All feature methods return Result<T> types for proper error handling:
Result<SidetoneResult> setSidetone(hid_device* device_handle, uint8_t level) override
{
// Map user level (0-128) to device range
uint8_t device_level = map(level, 0, 128, 0, 100);
std::array<uint8_t, 3> cmd { 0x00, 0x1d, device_level };
auto result = writeHID(device_handle, cmd);
if (!result) {
return result.error();
}
return SidetoneResult {
.current_level = level,
.min_level = 0,
.max_level = 128,
.device_min = 0,
.device_max = 100
};
}
Result<BatteryResult> getBattery(hid_device* device_handle) override
{
std::array<uint8_t, 64> buffer {};
buffer[0] = 0x05; // Report ID
auto result = getFeatureReport(device_handle, buffer);
if (!result) {
return result.error();
}
// Parse the response
int level = buffer[2];
bool charging = buffer[3] & 0x01;
return BatteryResult {
.level_percent = level,
.status = charging ? BATTERY_CHARGING : BATTERY_AVAILABLE,
.voltage_mv = std::nullopt // Optional: set if device reports voltage
};
}Use the DeviceError factory methods:
// Different error types
return DeviceError::timeout("Battery request timed out");
return DeviceError::hidError("Failed to write command");
return DeviceError::protocolError("Unexpected response");
return DeviceError::invalidParameter("Level out of range");
return DeviceError::notSupported("Feature not available");Add your device to lib/device_registry.cpp:
// Add include at the top
#include "devices/your_headset.hpp"
// Add registration in DeviceRegistry::initialize()
void DeviceRegistry::initialize()
{
// ... existing devices ...
// Your device
registerDevice(std::make_unique<YourHeadset>());
}If your device has a .cpp file (not header-only), add it to lib/CMakeLists.txt:
set(LIBRARY_SOURCES
# ... existing sources ...
${CMAKE_CURRENT_SOURCE_DIR}/devices/your_headset.cpp
)Build and test:
cd build
cmake ..
make
# Test with your device connected
./headsetcontrol -b # Battery
./headsetcontrol -s 50 # Sidetone
# Use the test device for development
./headsetcontrol --test-device -bHeadsetControl provides utilities in lib/devices/device_utils.hpp:
#include "devices/device_utils.hpp"
// Map ranges
uint8_t device_level = map(level, 0, 128, 0, 100);
// Map to discrete levels
std::array<uint8_t, 4> levels { 0, 1, 2, 3 };
uint8_t discrete = mapDiscrete(level, levels);
// Byte manipulation
uint16_t voltage = bytes_to_uint16_be(buffer[0], buffer[1]);
auto [high, low] = uint16_to_bytes_be(0x1234);
// Battery calibration
auto percent = voltageToPercent(voltage_mv, calibration_points);
// Capability details helper
return makeCapabilityDetail(0xffc0, 0x1, 3);| Capability | Type | Description |
|---|---|---|
CAP_SIDETONE |
Action | Microphone feedback level |
CAP_BATTERY_STATUS |
Info | Battery level and charging status |
CAP_NOTIFICATION_SOUND |
Action | Play notification sounds |
CAP_LIGHTS |
Action | LED/RGB control |
CAP_INACTIVE_TIME |
Action | Auto power-off timer |
CAP_CHATMIX_STATUS |
Info | Game/chat audio balance |
CAP_VOICE_PROMPTS |
Action | Voice feedback toggle |
CAP_ROTATE_TO_MUTE |
Action | Boom arm mute toggle |
CAP_EQUALIZER_PRESET |
Action | Built-in EQ presets |
CAP_EQUALIZER |
Action | Custom EQ curve |
CAP_PARAMETRIC_EQUALIZER |
Action | Parametric EQ bands |
CAP_MICROPHONE_MUTE_LED_BRIGHTNESS |
Action | Mute LED brightness |
CAP_MICROPHONE_VOLUME |
Action | Mic gain level |
CAP_VOLUME_LIMITER |
Action | Volume limiter toggle |
CAP_BT_WHEN_POWERED_ON |
Action | Bluetooth auto-connect |
CAP_BT_CALL_VOLUME |
Action | Bluetooth call volume |
See lib/devices/logitech_g535.hpp for a complete example using the HID++ protocol, or lib/devices/headsetcontrol_test.hpp for a reference implementation of all capabilities.
- USB Sniffing: Use Wireshark with USBPcap (Windows) or usbmon (Linux) to capture HID traffic
- Existing Implementations: Study similar devices in
lib/devices/for patterns - Protocol Templates: Reuse protocol implementations when possible
- Test Device: Use
--test-deviceduring development to test output formatting - Dev Mode: Use
--dev -- --listto explore HID interfaces on your device