diff --git a/README.md b/README.md index ff6289f..81055f4 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ A cross-platform tool to control USB gaming headsets on **Linux**, **macOS**, an | ROCCAT Elo 7.1 Air | All | | | | x | x | | | | | | | | | | | | | ROCCAT Elo 7.1 USB | All | | | | x | | | | | | | | | | | | | | Audeze Maxwell | All | x | x | | | x | x | x | | x | | | | | x | | | +| Lenovo Wireless VoIP Headset | All | x | x | | | x | | x | x | x | | | | | x | | | | HeadsetControl Test device | All | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | **Platform:** All = Linux, macOS, Windows | L/M = Linux and macOS only diff --git a/lib/device_registry.cpp b/lib/device_registry.cpp index 5babde1..f680e5f 100644 --- a/lib/device_registry.cpp +++ b/lib/device_registry.cpp @@ -40,6 +40,9 @@ // Audeze devices #include "devices/audeze_maxwell.hpp" +// Lenovo devices +#include "devices/lenovo_wireless_voip.hpp" + // Test device #include "devices/headsetcontrol_test.hpp" @@ -121,6 +124,9 @@ void DeviceRegistry::initialize() // Audeze devices registerDevice(std::make_unique()); + // Lenovo devices + registerDevice(std::make_unique()); + // Test device registerDevice(std::make_unique()); }); diff --git a/lib/devices/lenovo_wireless_voip.hpp b/lib/devices/lenovo_wireless_voip.hpp new file mode 100644 index 0000000..db472c4 --- /dev/null +++ b/lib/devices/lenovo_wireless_voip.hpp @@ -0,0 +1,291 @@ +#pragma once + +#include "hid_device.hpp" +#include +#include + +using namespace std::string_view_literals; + +namespace headsetcontrol { + +/** + * @brief Lenovo Wireless VoIP Headset + * + * Features: + * - Sidetone (6 levels) + * - Battery status (no charging status) + * - Inactive time with discrete levels + * - Voice prompts + * - Rotate to mute + * - Volume limiter + * - Equalizer (4 presets) TODO: 5-band parametric + */ +class LenovoWirelessVoip : public HIDDevice { +private: + static constexpr int MSG_SIZE = 61; + + static constexpr uint8_t ID_INDEX = 0; + static constexpr uint8_t CMD_INDEX = 1; + static constexpr uint8_t ERR_INDEX = 2; + + static constexpr uint8_t CMD_ID = 0x24; + static constexpr uint8_t RSP_ID = 0x27; + + static constexpr uint8_t STATUS_CMD = 0x01; + static constexpr uint8_t EQ_MODE_CMD = 0x02; + static constexpr uint8_t MIC_MUTE_CMD = 0x04; + static constexpr uint8_t VOICE_PROMPTS_CMD = 0x05; + static constexpr uint8_t VOLUME_LIMIT_CMD = 0x07; + static constexpr uint8_t INACTIVE_TIME_CMD = 0x08; + static constexpr uint8_t SIDETONE_CMD = 0x0E; + static constexpr uint8_t MIC_LIGHT_CMD = 0x15; + + static constexpr int EQ_PRESET_COUNT = 4; + + /** + * @brief Send feature report and get input report for a given command and payload + */ + Result sendGetReport(hid_device* device_handle, uint8_t cmd, std::span payload, std::array& response) const + { + std::array data {}; + + if (payload.size() > MSG_SIZE - 2) { + return DeviceError::hidError("Invalid payload size"); + } + + data[ID_INDEX] = CMD_ID; + data[CMD_INDEX] = cmd; + + std::copy(payload.begin(), payload.end(), data.begin() + 2); + + if (auto result = sendFeatureReport(device_handle, data); !result) { + return result.error(); + } + + auto read_result = readHIDTimeout(device_handle, response, hsc_device_timeout); + if (!read_result) { + return read_result.error(); + } + + if (*read_result != MSG_SIZE) { + return DeviceError::hidError("Failed to get input report"); + } + + if (response[ID_INDEX] != RSP_ID) { + return DeviceError::hidError("Wrong response report ID"); + } + + if (response[CMD_INDEX] != cmd) { + return DeviceError::hidError("Wrong command response"); + } + + // Check if device is connected + if (response[ERR_INDEX] != 0) { + return DeviceError::deviceOffline("Headset not connected to wireless receiver"); + } + + return {}; + } + +public: + static constexpr std::array PRODUCT_IDS { + 0xA07D // Lenovo Wireless VoIP Headset-Receiver + }; + + constexpr uint16_t getVendorId() const override + { + return 0x17EF; // Lenovo vendor ID + } + + constexpr std::vector getProductIds() const override + { + return { PRODUCT_IDS.begin(), PRODUCT_IDS.end() }; + } + + constexpr std::string_view getDeviceName() const override + { + return "Lenovo Wireless VoIP Headset"sv; + } + + constexpr int getCapabilities() const override + { + // Return bitmask of supported capabilities + return B(CAP_SIDETONE) | B(CAP_BATTERY_STATUS) | B(CAP_INACTIVE_TIME) + | B(CAP_VOICE_PROMPTS) | B(CAP_ROTATE_TO_MUTE) | B(CAP_EQUALIZER_PRESET) + | B(CAP_MICROPHONE_MUTE_LED_BRIGHTNESS) | B(CAP_VOLUME_LIMITER); + } + + constexpr capability_detail getCapabilityDetail([[maybe_unused]] enum capabilities cap) const override + { + return { .usagepage = 0xFF07, .usageid = 0x222, .interface_id = 0x03 }; + } + + uint8_t getEqualizerPresetsCount() const override + { + return EQ_PRESET_COUNT; + } + + std::optional getEqualizerPresets() const override + { + EqualizerPresets presets; + presets.presets = { + { "Music", {} }, + { "Movie", {} }, + { "Game", {} }, + { "Voice", {} } + }; + return presets; + } + + Result setSidetone(hid_device* device_handle, uint8_t level) override + { + std::array response {}; + std::array payload { 0x01, map(level, 0, 128, 0, 5) }; + + auto result = sendGetReport(device_handle, SIDETONE_CMD, payload, response); + if (!result) { + return result.error(); + } + + return SidetoneResult { + .current_level = response[4], + .min_level = 0, + .max_level = 128, + .device_min = 0, + .device_max = 5 + }; + } + + Result getBattery(hid_device* device_handle) override + { + std::array response {}; + + auto result = sendGetReport(device_handle, STATUS_CMD, {}, response); + if (!result) { + return result.error(); + } + + BatteryResult battery {}; + battery.level_percent = response[7]; + battery.status = BATTERY_AVAILABLE; + + return battery; + } + + Result setInactiveTime(hid_device* device_handle, uint8_t minutes) override + { + std::array response {}; + std::array payload { 0x01, 0x00 }; // Disabled by default + + // When enabled (non zero), hours = 1 << (payload[1] - 1) + // 0x00 -> disabled | 0x01 -> 1 hour | 0x02 -> 2 hours | 0x03 -> 4 hours | 0x04 -> 8 hours + if (minutes > 0) { + if (minutes <= 60) + payload[1] = 0x01; + else if (minutes <= 120) + payload[1] = 0x02; + else if (minutes <= 240) + payload[1] = 0x03; + else + payload[1] = 0x04; + } + + auto result = sendGetReport(device_handle, INACTIVE_TIME_CMD, payload, response); + if (!result) { + return result.error(); + } + + // Convert response back to minutes + unsigned int resp_minutes = response[4]; + if (resp_minutes != 0) { + // TODO: Headset supports up to 8 hours (480 seconds) + resp_minutes = std::min(60 * (1 << (resp_minutes - 1)), 255); + } + + return InactiveTimeResult { + .minutes = (uint8_t)resp_minutes, + .min_minutes = 0, + .max_minutes = 255 + }; + } + + Result setVoicePrompts(hid_device* device_handle, bool enabled) override + { + // Right now switching between "voice" and "off", but we also have an option for "beep" (0x02) + // that is the same as "voice", but the mute action uses a beep instead + std::array response {}; + std::array payload { 0x01, enabled ? uint8_t(0x01) : uint8_t(0x00) }; + + auto result = sendGetReport(device_handle, VOICE_PROMPTS_CMD, payload, response); + if (!result) { + return result.error(); + } + + return VoicePromptsResult { .enabled = response[4] != 0 }; + } + + Result setRotateToMute(hid_device* device_handle, bool enabled) override + { + std::array response {}; + std::array payload { 0x01, enabled ? uint8_t(0x01) : uint8_t(0x00) }; + + auto result = sendGetReport(device_handle, MIC_MUTE_CMD, payload, response); + if (!result) { + return result.error(); + } + + return RotateToMuteResult { .enabled = response[4] != 0 }; + } + + Result setEqualizerPreset(hid_device* device_handle, uint8_t preset) override + { + std::array response {}; + std::array payload { 0x01, preset }; + + // Music, Movie, Game and Voice presets + if (preset >= EQ_PRESET_COUNT) { + return DeviceError::invalidParameter("Device only supports presets 0-3"); + } + + auto result = sendGetReport(device_handle, EQ_MODE_CMD, payload, response); + if (!result) { + return result.error(); + } + + return EqualizerPresetResult { .preset = response[4], .total_presets = EQ_PRESET_COUNT }; + } + + Result setMicMuteLedBrightness(hid_device* device_handle, uint8_t brightness) override + { + + std::array response {}; + std::array payload { 0x01, (brightness > 0) ? uint8_t(0x00) : uint8_t(0x01) }; + + auto result = sendGetReport(device_handle, MIC_LIGHT_CMD, payload, response); + if (!result) { + return result.error(); + } + + return MicMuteLedBrightnessResult { + .brightness = brightness, + .min_brightness = 0, + .max_brightness = 1 + }; + } + + Result setVolumeLimiter(hid_device* device_handle, bool enabled) override + { + // Right now switching between "85db" and "off", but we also have an option for "80db" (0x01) + std::array response {}; + std::array payload { 0x01, enabled ? uint8_t(0x02) : uint8_t(0x00) }; + + auto result = sendGetReport(device_handle, VOLUME_LIMIT_CMD, payload, response); + if (!result) { + return result.error(); + } + + return VolumeLimiterResult { .enabled = response[4] != 0 }; + } +}; + +} // namespace headsetcontrol