diff --git a/README.md b/README.md index 59407922..894809ba 100644 --- a/README.md +++ b/README.md @@ -152,49 +152,51 @@ sudo udevadm control --reload-rules && sudo udevadm trigger ## Supported Devices -| 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 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Logitech G522 LIGHTSPEED | All | x | x | | | x | | | | | | | x | | | | | | -| Logitech G533 | All | x | x | | | x | | | | | | | | | | | | | -| Logitech G535 | All | x | x | | | x | | | | | | | | | | | | | -| Logitech G633/G635/G733/G933/G935 | All | x | x | | x | | | | | | | | | | | | | | -| Logitech G431/G432/G433 | All | x | | | | | | | | | | | | | | | | | -| Logitech G930 | All | x | x | | | | | | | | | | | | | | | | -| Logitech G PRO X 2 LIGHTSPEED | All | x | x | | | x | | | | x | x | x | | | | | | | -| Logitech G PRO Series | All | x | x | | | x | | | | | | | | | | | | | -| Logitech Zone Wired/Zone 750 | All | x | | | | | | x | x | | | | | | | | | | -| Logitech ASTRO A50 Gen 5 | All | x | x | | x | | x | | | | | x | | | | | | x | -| Corsair Headset Device | All | x | x | x | x | | | | | | | | | | | | | | -| Corsair Wireless V2 Headset Device | All | x | x | | | x | | | | | | | | | | | | | -| SteelSeries Arctis (1/7X/7P) Wireless | All | x | x | | | x | | | | | | | | | | | | | -| SteelSeries Arctis (7/Pro) | All | x | x | | x | x | x | | | | | | | | | | | | -| SteelSeries Arctis 9 | All | x | x | | | x | x | | | | | | | | | | | | -| SteelSeries Arctis Pro Wireless | All | x | x | | | x | | | | | | | | | | | | | -| SteelSeries Arctis Nova 3 | All | x | | | | | | | | x | x | | x | x | | | | | -| SteelSeries Arctis Nova (5/5X) | All | x | x | | | x | x | | | x | x | x | x | x | x | | | | -| SteelSeries Arctis Nova 7 | All | x | x | | | x | x | | | x | x | | x | x | x | x | x | | -| SteelSeries Arctis Nova 7P | All | | x | | | x | | | | x | x | | x | x | x | x | x | | -| SteelSeries Arctis 7+ | All | x | x | | | x | x | | | x | x | | | | | | | | -| SteelSeries Arctis Nova Pro Wireless | All | x | x | | x | x | | | | x | x | | | | | | | | -| SteelSeries Arctis Nova 3P Wireless | All | x | x | | | x | | | | x | x | x | | x | | | | | -| SteelSeries Arctis Buds | All | | x | | | | | | | | | | | | | | | | -| HyperX Cloud Alpha Wireless | All | x | x | | | x | | x | | | | | | | | | | | -| HyperX Cloud Flight Wireless | All | | x | | | | | | | | | | | | | | | | -| HyperX Cloud II Wireless | All | | x | | | x | | | | | | | | | | | | | -| HyperX Cloud 3 | All | x | | | | | | | | | | | | | | | | | -| HyperX Cloud II Wireless (Kingston) | All | x | x | | | x | | | | | | | | | | | | | -| 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 | | | x | -| Audeze Maxwell 2 | All | x | x | | | x | x | x | | x | | | | | | | | x | -| Lenovo Wireless VoIP Headset | All | x | x | | | x | | x | x | x | | | x | | x | | | | -| Plantronics Voyager 8200 UC (BT600) | L/W | x | x | | x | | | x | | | | | | | x | | | | -| Sony INZONE Buds | All | | x | | | | | | | | | | | | | | | | -| Sony INZONE H5 | All | x | x | | | | x | | | | | | | x | | | | | -| HeadsetControl Test device | All | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | +| 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 G522 LIGHTSPEED | All | x | x | | | x | | | | | | | x | | | | | | | +| Logitech G533 | All | x | x | | | x | | | | | | | | | | | | | | +| Logitech G535 | All | x | x | | | x | | | | | | | | | | | | | | +| Logitech G633/G635/G733/G933/G935 | All | x | x | | x | | | | | | | | | | | | | | | +| Logitech G431/G432/G433 | All | x | | | | | | | | | | | | | | | | | | +| Logitech G930 | All | x | x | | | | | | | | | | | | | | | | | +| Logitech G PRO X 2 LIGHTSPEED | All | x | x | | | x | | | | x | x | x | | | | | | | | +| Logitech G PRO Series | All | x | x | | | x | | | | | | | | | | | | | | +| Logitech Zone Wired/Zone 750 | All | x | | | | | | x | x | | | | | | | | | | | +| Logitech ASTRO A50 Gen 5 | All | x | x | | x | | x | | | | | x | | | | | | x | | +| Corsair Headset Device | All | x | x | x | x | | | | | | | | | | | | | | | +| Corsair Wireless V2 Headset Device | All | x | x | | | x | | | | | | | | | | | | | | +| SteelSeries Arctis (1/7X/7P) Wireless | All | x | x | | | x | | | | | | | | | | | | | | +| SteelSeries Arctis (7/Pro) | All | x | x | | x | x | x | | | | | | | | | | | | | +| SteelSeries Arctis 9 | All | x | x | | | x | x | | | | | | | | | | | | | +| SteelSeries Arctis Pro Wireless | All | x | x | | | x | | | | | | | | | | | | | | +| SteelSeries Arctis Nova 3 | All | x | | | | | | | | x | x | | x | x | | | | | | +| SteelSeries Arctis Nova (5/5X) | All | x | x | | | x | x | | | x | x | x | x | x | x | | | | | +| SteelSeries Arctis Nova 7 | All | x | x | | | x | x | | | x | x | | x | x | x | x | x | | x* | +| SteelSeries Arctis Nova 7P | All | | x | | | x | | | | x | x | | x | x | x | x | x | | | +| SteelSeries Arctis 7+ | All | x | x | | | x | x | | | x | x | | | | | | | | | +| SteelSeries Arctis Nova Pro Wireless | All | x | x | | x | x | | | | x | x | | | | | | | | | +| SteelSeries Arctis Nova 3P Wireless | All | x | x | | | x | | | | x | x | x | | x | | | | | | +| SteelSeries Arctis Buds | All | | x | | | | | | | | | | | | | | | | | +| HyperX Cloud Alpha Wireless | All | x | x | | | x | | x | | | | | | | | | | | | +| HyperX Cloud Flight Wireless | All | | x | | | | | | | | | | | | | | | | | +| HyperX Cloud II Wireless | All | | x | | | x | | | | | | | | | | | | | | +| HyperX Cloud 3 | All | x | | | | | | | | | | | | | | | | | | +| HyperX Cloud II Wireless (Kingston) | All | x | x | | | x | | | | | | | | | | | | | | +| 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 | | | x | | +| Audeze Maxwell 2 | All | x | x | | | x | x | x | | x | | | | | | | | x | | +| Lenovo Wireless VoIP Headset | All | x | x | | | x | | x | x | x | | | x | | x | | | | | +| Plantronics Voyager 8200 UC (BT600) | L/W | x | x | | x | | | x | | | | | | | x | | | | | +| Sony INZONE Buds | All | | x | | | | | | | | | | | | | | | | | +| Sony INZONE H5 | All | x | x | | | | x | | | | | | | x | | | | | | +| HeadsetControl Test device | All | x | x | 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 | L/W = Linux and Windows only +\* Sidetone status reading is currently verified only for the SteelSeries Arctis Nova 7 Gen 2 (`1038:227e`). + > **Note:** Some Corsair headsets may need additional configuration - see [Adding a Corsair device](docs/ADDING_A_CORSAIR_DEVICE.md). Some headsets (HS80, HS70 wired, RGB Elite, Virtuoso) expose sidetone via ALSA mixer instead. ## Usage @@ -212,6 +214,9 @@ headsetcontrol -b # Set sidetone level (0-128) headsetcontrol -s 64 +# Read the current sidetone level (when supported) +headsetcontrol -s + # Turn off LEDs headsetcontrol -l 0 diff --git a/cli/main.cpp b/cli/main.cpp index 72559802..1a99ff3e 100644 --- a/cli/main.cpp +++ b/cli/main.cpp @@ -37,8 +37,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -151,8 +151,9 @@ struct Options { std::optional noise_filter; // Info requests - bool request_battery = false; - bool request_chatmix = false; + bool request_battery = false; + bool request_chatmix = false; + bool request_sidetone = false; // Complex settings std::optional equalizer; @@ -204,7 +205,21 @@ std::optional configureParser(cli::ArgumentParser& parser, Opti .long_flag("version", opts.show_version, "Show version information") // === Feature Controls === - .value('s', "sidetone", opts.sidetone_level, uint8_t(0), uint8_t(128), "Set sidetone level", "LEVEL") + .custom('s', "sidetone", cli::ArgRequirement::Optional, [&opts](std::optional arg) -> std::optional { + if (!arg || arg->empty()) { + opts.sidetone_level.reset(); + opts.request_sidetone = true; + return std::nullopt; + } + + uint8_t level {}; + if (auto error = cli::parseInteger(*arg, level, uint8_t(0), uint8_t(128), "sidetone")) { + return error; + } + + opts.sidetone_level = level; + opts.request_sidetone = false; + return std::nullopt; }, "Get current sidetone level, or set it to LEVEL", "LEVEL") .flag('b', "battery", opts.request_battery, "Check battery level") .toggle('l', "light", opts.lights_enabled, "Turn lights off (0) or on (1)") .toggle('v', "voice-prompt", opts.voice_prompts_enabled, "Turn voice prompts off (0) or on (1)") @@ -420,7 +435,7 @@ class HIDEnumeration { if (devices_) hid_free_enumeration(devices_); } - HIDEnumeration(const HIDEnumeration&) = delete; + HIDEnumeration(const HIDEnumeration&) = delete; HIDEnumeration& operator=(const HIDEnumeration&) = delete; hid_device_info* get() const { return devices_; } @@ -513,7 +528,9 @@ FeatureResult convertToFeatureResult(const headsetcontrol::FeatureOutput& output // Handle sidetone special case if (output.sidetone) { - result.value = output.sidetone->current_level; + result.value = output.sidetone->current_level; + result.sidetone_device_level = output.sidetone->device_level; + result.sidetone_level_name = output.sidetone->level_name; } return result; @@ -791,7 +808,7 @@ namespace help { // Audio - value hints from capability descriptors sections.push_back({ "AUDIO", {} }); sections.back() - .add('s', "sidetone", getValueHint(CAP_SIDETONE), "Mic feedback level (0=off)", CAP_SIDETONE) + .add('s', "sidetone", "[LEVEL]", "Get current level or set mic feedback (0-128)", CAP_SIDETONE) .add("volume-limiter", getValueHint(CAP_VOLUME_LIMITER), "Enable/disable volume limiter", CAP_VOLUME_LIMITER); // Equalizer @@ -974,6 +991,7 @@ void initializeFeatureRequests(std::vector& devices, const Opt { CAP_BATTERY_STATUS, CAPABILITYTYPE_INFO, std::monostate {}, opts.request_battery, {} }, { CAP_INACTIVE_TIME, CAPABILITYTYPE_ACTION, g_feature_params.inactive_time_val, opts.inactive_time.has_value(), {} }, { CAP_CHATMIX_STATUS, CAPABILITYTYPE_INFO, std::monostate {}, opts.request_chatmix, {} }, + { CAP_SIDETONE_STATUS, CAPABILITYTYPE_INFO, std::monostate {}, opts.request_sidetone, {} }, { CAP_VOICE_PROMPTS, CAPABILITYTYPE_ACTION, g_feature_params.voice_prompts_val, opts.voice_prompts_enabled.has_value(), {} }, { CAP_ROTATE_TO_MUTE, CAPABILITYTYPE_ACTION, g_feature_params.rotate_to_mute_val, opts.rotate_to_mute_enabled.has_value(), {} }, { CAP_EQUALIZER_PRESET, CAPABILITYTYPE_ACTION, g_feature_params.equalizer_preset_val, opts.equalizer_preset.has_value(), {} }, @@ -1003,7 +1021,7 @@ void setupSignalHandler() #ifdef _WIN32 signal(SIGINT, signalHandler); #else - struct sigaction act {}; + struct sigaction act { }; act.sa_handler = signalHandler; sigaction(SIGINT, &act, nullptr); #endif diff --git a/cli/output/output.cpp b/cli/output/output.cpp index d193cf06..1b7febb1 100644 --- a/cli/output/output.cpp +++ b/cli/output/output.cpp @@ -23,7 +23,7 @@ using namespace headsetcontrol::serializers; // Constants // ============================================================================ -constexpr std::string_view API_VERSION = "1.4"; +constexpr std::string_view API_VERSION = "1.5"; constexpr std::string_view APP_NAME = "HeadsetControl"; // ============================================================================ @@ -71,6 +71,20 @@ void processChatmixResult(const FeatureResult& result, DeviceData& dev) } } +void processSidetoneResult(const FeatureResult& result, DeviceData& dev) +{ + if (result.status == FEATURE_SUCCESS || result.status == FEATURE_INFO) { + dev.sidetone = SidetoneData { + .level = result.value, + .device_level = result.sidetone_device_level.value_or(0), + .name = result.sidetone_level_name.value_or("") + }; + } else if (result.status == FEATURE_ERROR) { + dev.errors.emplace_back(capability_to_string(CAP_SIDETONE_STATUS), result.message); + dev.status = STATUS_PARTIAL; + } +} + // Process action capability result and add to device actions void processActionResult(const FeatureRequest& req, DeviceData& dev, std::string_view device_name) { @@ -99,6 +113,8 @@ void processFeatureRequest(const FeatureRequest& req, DeviceData& dev, std::stri processBatteryResult(req.result, dev); } else if (req.cap == CAP_CHATMIX_STATUS) { processChatmixResult(req.result, dev); + } else if (req.cap == CAP_SIDETONE_STATUS) { + processSidetoneResult(req.result, dev); } else if (req.type == CAPABILITYTYPE_ACTION) { processActionResult(req, dev, device_name); } @@ -179,8 +195,7 @@ void processFeatureRequest(const FeatureRequest& req, DeviceData& dev, std::stri for (const auto& preset : presets->presets) { preset_data.push_back(EqualizerPresetData { .name = preset.name, - .values = preset.values - }); + .values = preset.values }); } dev.equalizer_presets = std::move(preset_data); } @@ -293,6 +308,10 @@ void outputYaml(const OutputData& data) s.write("chatmix", *dev.chatmix); } + if (dev.sidetone.has_value()) { + dev.sidetone->serialize(s); + } + if (!dev.errors.empty()) { s.beginObject("errors"); for (const auto& err : dev.errors) { @@ -404,6 +423,14 @@ void outputEnv(const OutputData& data) s.write(prefix + "_CHATMIX", *dev.chatmix); } + if (dev.sidetone.has_value()) { + s.write(prefix + "_SIDETONE_LEVEL", dev.sidetone->level); + s.write(prefix + "_SIDETONE_DEVICE_LEVEL", dev.sidetone->device_level); + if (!dev.sidetone->name.empty()) { + s.write(prefix + "_SIDETONE_NAME", dev.sidetone->name); + } + } + s.write(prefix + "_ERROR_COUNT", static_cast(dev.errors.size())); for (size_t j = 0; j < dev.errors.size(); ++j) { s.write(std::format("{}_ERROR_{}_SOURCE", prefix, j + 1), dev.errors[j].source); @@ -471,6 +498,16 @@ void outputStandard(const OutputData& data, bool print_capabilities) outputted = true; } + if (dev.sidetone.has_value()) { + if (dev.sidetone->name.empty()) { + s.println("Sidetone: {} (device level {})", dev.sidetone->level, dev.sidetone->device_level); + } else { + s.println("Sidetone: {} ({}; device level {})", dev.sidetone->name, + dev.sidetone->level, dev.sidetone->device_level); + } + outputted = true; + } + for (const auto& err : dev.errors) { s.println("Error: [{}] {}", err.source, err.message); outputted = true; @@ -545,6 +582,8 @@ void outputShort(const OutputData& data, bool print_capabilities) } } else if (dev.chatmix.has_value()) { s.printValue(*dev.chatmix); + } else if (dev.sidetone.has_value()) { + s.printValue(dev.sidetone->level); } } diff --git a/cli/output/output_data.hpp b/cli/output/output_data.hpp index 5703853c..cfceb4e2 100644 --- a/cli/output/output_data.hpp +++ b/cli/output/output_data.hpp @@ -102,6 +102,23 @@ struct ActionData { } }; +struct SidetoneData { + int level = 0; + int device_level = 0; + std::string name; + + void serialize(Serializer& s) const + { + s.beginObject("sidetone"); + s.write("level", level); + s.write("device_level", device_level); + if (!name.empty()) { + s.write("name", name); + } + s.endObject(); + } +}; + struct ErrorData { std::string source; std::string message; @@ -178,6 +195,7 @@ struct DeviceData { std::optional battery; std::optional chatmix; + std::optional sidetone; std::optional equalizer; std::optional equalizer_presets_count; std::optional> equalizer_presets; @@ -233,6 +251,10 @@ struct DeviceData { s.write("chatmix", *chatmix); } + if (sidetone.has_value()) { + sidetone->serialize(s); + } + if (!errors.empty()) { s.beginObject("errors"); for (const auto& err : errors) { diff --git a/docs/LIBRARY_USAGE.md b/docs/LIBRARY_USAGE.md index cf8afc07..13b84058 100644 --- a/docs/LIBRARY_USAGE.md +++ b/docs/LIBRARY_USAGE.md @@ -28,7 +28,7 @@ headsetcontrol -o env { "name": "HeadsetControl", "version": "3.0.0", - "api_version": "1.4", + "api_version": "1.5", "device_count": 1, "devices": [ { @@ -70,6 +70,9 @@ Query and set values in one call: ```bash # Set sidetone and get battery headsetcontrol -s 64 -b -o json + +# Read the current sidetone setting (when supported) +headsetcontrol -s -o json ``` Action results include status: @@ -303,6 +306,15 @@ if (headset.supports(CAP_CHATMIX_STATUS)) { std::cout << "Chat-mix: " << result->level << "\n"; } } + +// Get current sidetone level +if (headset.supports(CAP_SIDETONE_STATUS)) { + auto result = headset.getSidetone(); + if (result) { + std::cout << "Sidetone: " << (int)result->current_level + << " (device level " << (int)result->device_level << ")\n"; + } +} ``` ### Audio Controls @@ -690,6 +702,19 @@ if (result == HSC_RESULT_OK) { ### Setting Features +Current sidetone can be queried separately on devices that advertise +`HSC_CAP_SIDETONE_STATUS`: + +```c +hsc_sidetone_status_t sidetone_status; +if (hsc_get_sidetone(headset, &sidetone_status) == HSC_RESULT_OK) { + printf("Sidetone: %u (%s, device level %u)\n", + sidetone_status.current_level, + sidetone_status.level_name ? sidetone_status.level_name : "unnamed", + sidetone_status.device_level); +} +``` + ```c // Sidetone (0-128) hsc_sidetone_t sidetone_result; diff --git a/lib/capability_descriptors.hpp b/lib/capability_descriptors.hpp index 1963ad42..0aeca6fd 100644 --- a/lib/capability_descriptors.hpp +++ b/lib/capability_descriptors.hpp @@ -230,6 +230,17 @@ inline constexpr std::array CAPABILITY_D .min_value = 0, .max_value = 2, .value_hint = "<0|1|2>" }, + + // CAP_SIDETONE_STATUS + { + .cap = CAP_SIDETONE_STATUS, + .type = CAPABILITYTYPE_INFO, + .name = "sidetone-status", + .short_flag = "", + .description = "Show current sidetone level", + .min_value = std::nullopt, + .max_value = std::nullopt, + .value_hint = "" }, } }; /** diff --git a/lib/device.hpp b/lib/device.hpp index 9a68ec0b..a0d76af0 100644 --- a/lib/device.hpp +++ b/lib/device.hpp @@ -38,24 +38,25 @@ extern int hsc_device_timeout; * To add a new capability, add a single X() entry here. * The enum, string functions, and short char are all generated automatically. */ -#define CAPABILITIES_XLIST \ - X(CAP_SIDETONE, "sidetone", 's') \ - X(CAP_BATTERY_STATUS, "battery", 'b') \ - X(CAP_NOTIFICATION_SOUND, "notification sound", 'n') \ - X(CAP_LIGHTS, "lights", 'l') \ - X(CAP_INACTIVE_TIME, "inactive time", 'i') \ - X(CAP_CHATMIX_STATUS, "chatmix", 'm') \ - X(CAP_VOICE_PROMPTS, "voice prompts", 'v') \ - X(CAP_ROTATE_TO_MUTE, "rotate to mute", 'r') \ - X(CAP_EQUALIZER_PRESET, "equalizer preset", 'p') \ - X(CAP_EQUALIZER, "equalizer", 'e') \ - X(CAP_PARAMETRIC_EQUALIZER, "parametric equalizer", 'q') \ - X(CAP_MICROPHONE_MUTE_LED_BRIGHTNESS, "microphone mute led brightness", 't') \ - X(CAP_MICROPHONE_VOLUME, "microphone volume", 'o') \ - X(CAP_VOLUME_LIMITER, "volume limiter", '\0') \ - X(CAP_BT_WHEN_POWERED_ON, "bluetooth when powered on", '\0') \ - X(CAP_BT_CALL_VOLUME, "bluetooth call volume", '\0') \ - X(CAP_NOISE_FILTER, "microphone noise filter", '\0') +#define CAPABILITIES_XLIST \ + X(CAP_SIDETONE, "sidetone", 's') \ + X(CAP_BATTERY_STATUS, "battery", 'b') \ + X(CAP_NOTIFICATION_SOUND, "notification sound", 'n') \ + X(CAP_LIGHTS, "lights", 'l') \ + X(CAP_INACTIVE_TIME, "inactive time", 'i') \ + X(CAP_CHATMIX_STATUS, "chatmix", 'm') \ + X(CAP_VOICE_PROMPTS, "voice prompts", 'v') \ + X(CAP_ROTATE_TO_MUTE, "rotate to mute", 'r') \ + X(CAP_EQUALIZER_PRESET, "equalizer preset", 'p') \ + X(CAP_EQUALIZER, "equalizer", 'e') \ + X(CAP_PARAMETRIC_EQUALIZER, "parametric equalizer", 'q') \ + X(CAP_MICROPHONE_MUTE_LED_BRIGHTNESS, "microphone mute led brightness", 't') \ + X(CAP_MICROPHONE_VOLUME, "microphone volume", 'o') \ + X(CAP_VOLUME_LIMITER, "volume limiter", '\0') \ + X(CAP_BT_WHEN_POWERED_ON, "bluetooth when powered on", '\0') \ + X(CAP_BT_CALL_VOLUME, "bluetooth call volume", '\0') \ + X(CAP_NOISE_FILTER, "microphone noise filter", '\0') \ + X(CAP_SIDETONE_STATUS, "sidetone status", '\0') /** @brief A list of all features settable/queryable for headsets * @@ -66,7 +67,7 @@ enum capabilities { #define X(id, name, short_char) id, CAPABILITIES_XLIST #undef X - NUM_CAPABILITIES + NUM_CAPABILITIES }; enum capabilitytype { CAPABILITYTYPE_ACTION, @@ -195,6 +196,8 @@ struct FeatureResult { std::optional battery_voltage_mv; std::optional battery_time_to_full_min; std::optional battery_time_to_empty_min; + std::optional sidetone_device_level; + std::optional sidetone_level_name; }; // FeatureRequest is defined after EqualizerSettings and ParametricEqualizerSettings diff --git a/lib/devices/headsetcontrol_test.hpp b/lib/devices/headsetcontrol_test.hpp index 12902a06..b7edf327 100644 --- a/lib/devices/headsetcontrol_test.hpp +++ b/lib/devices/headsetcontrol_test.hpp @@ -69,7 +69,7 @@ class HeadsetControlTest : public HIDDevice { | B(CAP_EQUALIZER) | B(CAP_PARAMETRIC_EQUALIZER) | B(CAP_MICROPHONE_MUTE_LED_BRIGHTNESS) | B(CAP_MICROPHONE_VOLUME) | B(CAP_VOLUME_LIMITER) | B(CAP_BT_WHEN_POWERED_ON) | B(CAP_BT_CALL_VOLUME) - | B(CAP_NOISE_FILTER); + | B(CAP_NOISE_FILTER) | B(CAP_SIDETONE_STATUS); } std::optional getEqualizerInfo() const override @@ -165,6 +165,23 @@ class HeadsetControlTest : public HIDDevice { }; } + Result getSidetone([[maybe_unused]] hid_device* device_handle) override + { + if (test_profile == 1) { + return DeviceError::hidError("Test error condition"); + } + + return SidetoneResult { + .current_level = 85, + .min_level = 0, + .max_level = 128, + .device_min = 0, + .device_max = 3, + .device_level = 2, + .level_name = "Medium" + }; + } + Result setLights([[maybe_unused]] hid_device* device_handle, bool on) override { return LightsResult { .enabled = on }; diff --git a/lib/devices/hid_device.hpp b/lib/devices/hid_device.hpp index 5a8246e6..1baa0c73 100644 --- a/lib/devices/hid_device.hpp +++ b/lib/devices/hid_device.hpp @@ -131,6 +131,14 @@ class HIDDevice { return DeviceError::notSupported("Device does not support sidetone"); } + /** + * @brief Get the current sidetone level + */ + virtual Result getSidetone(hid_device* /*device_handle*/) + { + return DeviceError::notSupported("Device does not support reading sidetone"); + } + /** * @brief Get battery information with rich details * @@ -330,7 +338,7 @@ class HIDDevice { /** * @brief Get HID interface singleton */ - [[nodiscard]] static auto getHIDInterface() -> RealHIDInterface& + [[nodiscard]] virtual auto getHIDInterface() const -> HIDInterface& { static RealHIDInterface instance; return instance; diff --git a/lib/devices/steelseries_arctis_nova_7.hpp b/lib/devices/steelseries_arctis_nova_7.hpp index 6899f9a4..732cb918 100644 --- a/lib/devices/steelseries_arctis_nova_7.hpp +++ b/lib/devices/steelseries_arctis_nova_7.hpp @@ -5,6 +5,7 @@ #include "protocols/steelseries_protocol.hpp" #include #include +#include #include using namespace std::string_view_literals; @@ -68,12 +69,20 @@ class SteelSeriesArctisNova7 : public protocols::SteelSeriesNovaDevice getEqualizerInfo() const override @@ -177,15 +186,83 @@ class SteelSeriesArctisNova7 : public protocols::SteelSeriesNovaDevice SAVE_COMMAND { 0x00, 0x09 }; + if (auto result = sendCommand(device_handle, SAVE_COMMAND); !result) { + return result.error(); + } + } + return SidetoneResult { .current_level = level, .min_level = 0, .max_level = 128, .device_min = 0x0, - .device_max = 0x3 + .device_max = 0x3, + .device_level = mapped, + .level_name = std::string(sidetoneLevelName(mapped)) }; } + Result getSidetone(hid_device* device_handle) override + { + if (getMatchedProductId() != 0x227e) { + return DeviceError::notSupported( + "Reading sidetone is only verified for the Arctis Nova 7 Gen 2"); + } + + constexpr std::array REQUEST { 0x00, 0x20 }; + if (auto result = sendCommand(device_handle, REQUEST); !result) { + return result.error(); + } + + // Status reports (0xb0) can arrive asynchronously on this interface. + // Ignore those while waiting for the requested audio settings report. + constexpr int MAX_REPORTS = 8; + for (int report = 0; report < MAX_REPORTS; ++report) { + std::array response {}; + auto read_result = readHIDTimeout(device_handle, response, hsc_device_timeout); + if (!read_result) { + return read_result.error(); + } + + const size_t bytes_read = *read_result; + if (bytes_read == 0) { + return DeviceError::timeout("No SteelSeries settings response received"); + } + + if (bytes_read < 4) { + return DeviceError::protocolError("SteelSeries settings response too short"); + } + + if (response[0] == 0xb0) { + continue; + } + + if (response[0] != 0x20) { + return DeviceError::protocolError("Unexpected SteelSeries settings response"); + } + + const uint8_t raw_level = response[2]; + if (raw_level >= SIDETONE_LEVELS.size()) { + return DeviceError::protocolError("Invalid SteelSeries sidetone value"); + } + + return SidetoneResult { + .current_level = SIDETONE_LEVELS[raw_level], + .min_level = 0, + .max_level = 128, + .device_min = 0, + .device_max = 3, + .is_muted = raw_level == 0, + .device_level = raw_level, + .level_name = std::string(sidetoneLevelName(raw_level)) + }; + } + + return DeviceError::protocolError("No SteelSeries settings response received"); + } + Result setInactiveTime(hid_device* device_handle, uint8_t minutes) override { std::array cmd { 0x00, 0xa3, minutes }; @@ -386,5 +463,14 @@ class SteelSeriesArctisNova7 : public protocols::SteelSeriesNovaDevice SIDETONE_LEVELS { 0, 43, 85, 128 }; + + [[nodiscard]] static constexpr std::string_view sidetoneLevelName(uint8_t raw_level) + { + constexpr std::array names { "Off", "Low", "Medium", "High" }; + return raw_level < names.size() ? names[raw_level] : "Unknown"; + } }; } // namespace headsetcontrol diff --git a/lib/feature_handlers.hpp b/lib/feature_handlers.hpp index 538b3b57..e92d443a 100644 --- a/lib/feature_handlers.hpp +++ b/lib/feature_handlers.hpp @@ -340,6 +340,14 @@ inline void FeatureHandlerRegistry::registerAllHandlers() return r.error(); return FeatureOutput::success(r->level); }); + + // CAP_SIDETONE_STATUS + registerHandler(CAP_SIDETONE_STATUS, [](HIDDevice* dev, hid_device* h, const FeatureParam&) -> Result { + auto r = dev->getSidetone(h); + if (r.hasError()) + return r.error(); + return FeatureOutput::fromSidetone(r.value()); + }); } } // namespace headsetcontrol diff --git a/lib/headsetcontrol.cpp b/lib/headsetcontrol.cpp index b83ee2b4..56da583d 100644 --- a/lib/headsetcontrol.cpp +++ b/lib/headsetcontrol.cpp @@ -74,7 +74,7 @@ namespace { class HeadsetImpl { public: HeadsetImpl(HIDDevice* device, uint16_t product_id, - bool is_test_device = false, + bool is_test_device = false, std::string vendor_name = {}, std::string product_name = {}) : device_(device) , product_id_(product_id) @@ -255,6 +255,11 @@ Result Headset::getChatmix() HEADSET_FEATURE_IMPL(CAP_CHATMIX_STATUS, getChatmix); } +Result Headset::getSidetone() +{ + HEADSET_FEATURE_IMPL(CAP_SIDETONE_STATUS, getSidetone); +} + Result Headset::setSidetone(uint8_t level) { HEADSET_FEATURE_IMPL(CAP_SIDETONE, setSidetone, level); diff --git a/lib/headsetcontrol.hpp b/lib/headsetcontrol.hpp index 7e6449d5..55322d50 100644 --- a/lib/headsetcontrol.hpp +++ b/lib/headsetcontrol.hpp @@ -129,6 +129,12 @@ class Headset { */ [[nodiscard]] Result getChatmix(); + /** + * @brief Get the current sidetone level + * @return Sidetone info or error + */ + [[nodiscard]] Result getSidetone(); + // ======================================================================== // Audio Controls // ======================================================================== diff --git a/lib/headsetcontrol_c.cpp b/lib/headsetcontrol_c.cpp index d1dca3cc..9fe1e33b 100644 --- a/lib/headsetcontrol_c.cpp +++ b/lib/headsetcontrol_c.cpp @@ -18,6 +18,7 @@ struct HeadsetWrapper { std::string vendor_name_str; std::string product_name_str; std::optional equalizer_presets_cache; + std::string sidetone_level_name_cache; explicit HeadsetWrapper(headsetcontrol::Headset&& h) : headset(std::move(h)) @@ -83,6 +84,19 @@ const EqualizerPresets* getCachedEqualizerPresets(HeadsetWrapper& wrapper) return &(*wrapper.equalizer_presets_cache); } +void copySidetoneStatus(const headsetcontrol::SidetoneResult& source, + hsc_sidetone_status_t& destination, std::string& level_name_cache) +{ + destination.current_level = source.current_level; + destination.min_level = source.min_level; + destination.max_level = source.max_level; + destination.device_level = source.device_level; + destination.device_min = source.device_min; + destination.device_max = source.device_max; + level_name_cache = source.level_name.value_or(""); + destination.level_name = level_name_cache.empty() ? nullptr : level_name_cache.c_str(); +} + } // namespace // ============================================================================ @@ -272,6 +286,22 @@ hsc_result_t hsc_get_chatmix(hsc_headset_t headset, hsc_chatmix_t* chatmix) return HSC_RESULT_OK; } +hsc_result_t hsc_get_sidetone(hsc_headset_t headset, hsc_sidetone_status_t* sidetone) +{ + if (!headset || !sidetone) { + return HSC_RESULT_INVALID_PARAM; + } + + auto& wrapper = *static_cast(headset); + auto result = wrapper.headset.getSidetone(); + if (!result) { + return toErrorCode(result.error()); + } + + copySidetoneStatus(*result, *sidetone, wrapper.sidetone_level_name_cache); + return HSC_RESULT_OK; +} + // ============================================================================ // Audio Controls // ============================================================================ @@ -346,7 +376,7 @@ const char* hsc_get_equalizer_preset_name(hsc_headset_t headset, int preset) return nullptr; } - auto& wrapper = *static_cast(headset); + auto& wrapper = *static_cast(headset); const auto* presets = getCachedEqualizerPresets(wrapper); if (!presets || preset >= static_cast(presets->presets.size())) { return nullptr; @@ -361,7 +391,7 @@ int hsc_get_equalizer_preset_band_count(hsc_headset_t headset, int preset) return 0; } - auto& wrapper = *static_cast(headset); + auto& wrapper = *static_cast(headset); const auto* presets = getCachedEqualizerPresets(wrapper); if (!presets || preset >= static_cast(presets->presets.size())) { return 0; @@ -380,7 +410,7 @@ hsc_result_t hsc_get_equalizer_preset_bands( return HSC_RESULT_INVALID_PARAM; } - auto& wrapper = *static_cast(headset); + auto& wrapper = *static_cast(headset); const auto* presets = getCachedEqualizerPresets(wrapper); if (!presets) { return HSC_RESULT_NOT_SUPPORTED; diff --git a/lib/headsetcontrol_c.h b/lib/headsetcontrol_c.h index 7877d267..83407991 100644 --- a/lib/headsetcontrol_c.h +++ b/lib/headsetcontrol_c.h @@ -99,7 +99,9 @@ typedef enum { HSC_CAP_VOLUME_LIMITER = 13, HSC_CAP_BT_WHEN_POWERED_ON = 14, HSC_CAP_BT_CALL_VOLUME = 15, - HSC_NUM_CAPABILITIES = 16, + HSC_CAP_NOISE_FILTER = 16, + HSC_CAP_SIDETONE_STATUS = 17, + HSC_NUM_CAPABILITIES = 18, } hsc_capability_t; /* ============================================================================ @@ -132,6 +134,16 @@ typedef struct { uint8_t max_level; } hsc_sidetone_t; +typedef struct { + uint8_t current_level; + uint8_t min_level; + uint8_t max_level; + uint8_t device_level; + uint8_t device_min; + uint8_t device_max; + const char* level_name; /**< Name valid until next query or headset release; NULL if unavailable */ +} hsc_sidetone_status_t; + typedef struct { int level; /**< Chat-mix level (0-128) */ int game_volume_percent; /**< Game audio percentage */ @@ -271,6 +283,15 @@ HSC_API hsc_result_t hsc_get_battery(hsc_headset_t headset, hsc_battery_t* batte */ HSC_API hsc_result_t hsc_get_chatmix(hsc_headset_t headset, hsc_chatmix_t* chatmix); +/** + * @brief Get current sidetone level + * + * @param headset Headset handle + * @param[out] sidetone Sidetone info structure to fill + * @return HSC_RESULT_OK on success, negative error code on failure + */ +HSC_API hsc_result_t hsc_get_sidetone(hsc_headset_t headset, hsc_sidetone_status_t* sidetone); + /* ============================================================================ * Audio Controls * ============================================================================ */ diff --git a/lib/result_types.hpp b/lib/result_types.hpp index 9ac4f04c..79a5c156 100644 --- a/lib/result_types.hpp +++ b/lib/result_types.hpp @@ -183,7 +183,9 @@ struct SidetoneResult { uint8_t max_level; // Maximum supported level uint8_t device_min; // Device's native min (e.g., 200 for Corsair) uint8_t device_max; // Device's native max (e.g., 255 for Corsair) - bool is_muted = false; // Whether sidetone is muted + bool is_muted = false; // Whether sidetone is muted + uint8_t device_level = 0; // Current level in the device's native range + std::optional level_name; // Named level, when exposed by the device }; /** diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 55077706..88ce025b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,6 +14,7 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/test_string_escaping.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test_library_api.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test_protocols.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/test_steelseries_sidetone.cpp ) # Export to parent scope diff --git a/tests/test_cli_output.cpp b/tests/test_cli_output.cpp index 6ca6d16e..70382fe1 100644 --- a/tests/test_cli_output.cpp +++ b/tests/test_cli_output.cpp @@ -348,6 +348,53 @@ void testCliStandardNoArgs() std::cout << " ✓ CLI standard no-args output is correct" << std::endl; } +void testCliSidetoneStatusOutputs() +{ + std::cout << " Testing sidetone status in all output formats..." << std::endl; + + const std::string base = HEADSETCONTROL_EXE " --test-device -d 0xf00b:0xa00c -s -o "; + + std::string json = exec((base + "json 2>&1").c_str()); + ASSERT_CONTAINS(json, "\"sidetone\": {", "JSON should have sidetone object"); + ASSERT_CONTAINS(json, "\"level\": 85", "JSON should have normalized level"); + ASSERT_CONTAINS(json, "\"device_level\": 2", "JSON should have native level"); + ASSERT_CONTAINS(json, "\"name\": \"Medium\"", "JSON should have named level"); + + std::string yaml = exec((base + "yaml 2>&1").c_str()); + ASSERT_CONTAINS(yaml, "sidetone:", "YAML should have sidetone object"); + ASSERT_CONTAINS(yaml, "device_level: 2", "YAML should have native level"); + ASSERT_CONTAINS(yaml, "name: \"Medium\"", "YAML should have named level"); + + std::string env = exec((base + "env 2>&1").c_str()); + ASSERT_CONTAINS(env, "DEVICE_0_SIDETONE_LEVEL=85", "ENV should have normalized level"); + ASSERT_CONTAINS(env, "DEVICE_0_SIDETONE_DEVICE_LEVEL=2", "ENV should have native level"); + ASSERT_CONTAINS(env, "DEVICE_0_SIDETONE_NAME=\"Medium\"", "ENV should have named level"); + + std::string standard = exec(HEADSETCONTROL_EXE " --test-device -d 0xf00b:0xa00c -s 2>&1"); + ASSERT_CONTAINS(standard, "Sidetone: Medium (85; device level 2)", + "standard output should show normalized, native, and named level"); + + std::string long_query = exec(HEADSETCONTROL_EXE " --test-device -d 0xf00b:0xa00c --sidetone 2>&1"); + ASSERT_CONTAINS(long_query, "Sidetone: Medium (85; device level 2)", + "long sidetone option without a value should query"); + + std::string short_set = exec(HEADSETCONTROL_EXE " --test-device -d 0xf00b:0xa00c -s 64 2>&1"); + ASSERT_CONTAINS(short_set, "Successfully set sidetone!", + "short sidetone option with a value should set"); + + std::string long_set = exec(HEADSETCONTROL_EXE " --test-device -d 0xf00b:0xa00c --sidetone 64 2>&1"); + ASSERT_CONTAINS(long_set, "Successfully set sidetone!", + "long sidetone option with a value should set"); + + std::string help = exec(HEADSETCONTROL_EXE " --help-all 2>&1"); + ASSERT_CONTAINS(help, "-s, --sidetone [LEVEL]", + "help should document the optional sidetone level"); + ASSERT_NOT_CONTAINS(help, "--sidetone-status", + "help should not expose a separate sidetone status option"); + + std::cout << " ✓ Sidetone status output is correct" << std::endl; +} + // ============================================================================ // Short Output Tests // ============================================================================ @@ -448,6 +495,7 @@ void runAllCliOutputTests() runTest("Standard Output", testCliStandardOutput); runTest("Standard Battery Details", testCliStandardBatteryDetails); runTest("Standard No Args", testCliStandardNoArgs); + runTest("Sidetone Status Outputs", testCliSidetoneStatusOutputs); std::cout << "\n=== Short Output Tests ===" << std::endl; runTest("Short Output", testCliShortOutput); diff --git a/tests/test_library_api.cpp b/tests/test_library_api.cpp index ee91f1e1..7f45e3b7 100644 --- a/tests/test_library_api.cpp +++ b/tests/test_library_api.cpp @@ -263,6 +263,9 @@ void testCNullHandling() hsc_chatmix_t chatmix; ASSERT_EQ(HSC_RESULT_INVALID_PARAM, hsc_get_chatmix(nullptr, &chatmix), "get_chatmix(null) should fail"); + hsc_sidetone_status_t sidetone_status; + ASSERT_EQ(HSC_RESULT_INVALID_PARAM, hsc_get_sidetone(nullptr, &sidetone_status), "get_sidetone(null) should fail"); + ASSERT_EQ(HSC_RESULT_INVALID_PARAM, hsc_set_sidetone(nullptr, 64, nullptr), "set_sidetone(null) should fail"); ASSERT_EQ(HSC_RESULT_INVALID_PARAM, hsc_set_lights(nullptr, true), "set_lights(null) should fail"); @@ -306,6 +309,7 @@ void testCppTestDeviceMode() ASSERT_TRUE(headset.supports(CAP_BATTERY_STATUS), "Test device should support battery"); ASSERT_TRUE(headset.supports(CAP_SIDETONE), "Test device should support sidetone"); ASSERT_TRUE(headset.supports(CAP_CHATMIX_STATUS), "Test device should support chatmix"); + ASSERT_TRUE(headset.supports(CAP_SIDETONE_STATUS), "Test device should support sidetone status"); // Test battery auto battery = headset.getBattery(); @@ -317,6 +321,11 @@ void testCppTestDeviceMode() ASSERT_TRUE(sidetone.hasValue(), "Sidetone should return success"); ASSERT_EQ(64, sidetone->current_level, "Sidetone level should be 64"); + auto sidetone_status = headset.getSidetone(); + ASSERT_TRUE(sidetone_status.hasValue(), "Sidetone status should return success"); + ASSERT_EQ(85, sidetone_status->current_level, "Sidetone status should be 85"); + ASSERT_EQ(2, sidetone_status->device_level, "Native sidetone level should be 2"); + // Test chatmix auto chatmix = headset.getChatmix(); ASSERT_TRUE(chatmix.hasValue(), "Chatmix should return success"); @@ -357,18 +366,14 @@ void testCppTestDeviceMode() // Test parametric equalizer ASSERT_TRUE(headset.supports(CAP_PARAMETRIC_EQUALIZER), "Test device should support parametric EQ"); ParametricEqualizerSettings peq_settings; - peq_settings.bands.push_back({ - .frequency = 100, - .gain = 3.0f, - .q_factor = 1.0f, - .type = EqualizerFilterType::LowShelf - }); - peq_settings.bands.push_back({ - .frequency = 1000, - .gain = 0.0f, - .q_factor = 1.414f, - .type = EqualizerFilterType::Peaking - }); + peq_settings.bands.push_back({ .frequency = 100, + .gain = 3.0f, + .q_factor = 1.0f, + .type = EqualizerFilterType::LowShelf }); + peq_settings.bands.push_back({ .frequency = 1000, + .gain = 0.0f, + .q_factor = 1.414f, + .type = EqualizerFilterType::Peaking }); auto peq_result = headset.setParametricEqualizer(peq_settings); ASSERT_TRUE(peq_result.hasValue(), "Parametric equalizer should return success"); @@ -420,6 +425,7 @@ void testCTestDeviceMode() // Verify capabilities ASSERT_TRUE(hsc_supports(headsets[i], HSC_CAP_BATTERY_STATUS), "Should support battery"); ASSERT_TRUE(hsc_supports(headsets[i], HSC_CAP_SIDETONE), "Should support sidetone"); + ASSERT_TRUE(hsc_supports(headsets[i], HSC_CAP_SIDETONE_STATUS), "Should support sidetone status"); // Test battery hsc_battery_t battery; @@ -431,6 +437,13 @@ void testCTestDeviceMode() ASSERT_EQ(HSC_RESULT_OK, hsc_set_sidetone(headsets[i], 64, &sidetone), "Sidetone should succeed"); ASSERT_EQ(64, sidetone.current_level, "Sidetone level should be 64"); + hsc_sidetone_status_t sidetone_status; + ASSERT_EQ(HSC_RESULT_OK, hsc_get_sidetone(headsets[i], &sidetone_status), "Sidetone status should succeed"); + ASSERT_EQ(85, sidetone_status.current_level, "Sidetone status should be 85"); + ASSERT_EQ(2, sidetone_status.device_level, "Native sidetone level should be 2"); + ASSERT_NOT_NULL(sidetone_status.level_name, "Sidetone name should be available"); + ASSERT_EQ(std::string("Medium"), std::string(sidetone_status.level_name), "Sidetone name should match"); + ASSERT_EQ(4, hsc_get_equalizer_presets_count(headsets[i]), "Preset count should be 4"); ASSERT_EQ(std::string("Flat"), std::string(hsc_get_equalizer_preset_name(headsets[i], 0)), "Flat preset name should match"); ASSERT_EQ(10, hsc_get_equalizer_preset_band_count(headsets[i], 0), "Flat preset should have 10 bands"); @@ -524,7 +537,7 @@ void testVendorProductNames() headsetcontrol::enableTestDevice(true); // C++ API: verify test device names are non-empty and correct - auto headsets = headsetcontrol::discover(); + auto headsets = headsetcontrol::discover(); bool foundTestDevice = false; for (auto& headset : headsets) { if (headset.vendorId() == 0xF00B && headset.productId() == 0xA00C) { @@ -576,7 +589,7 @@ void testEqualizerPresetConsistency() headsetcontrol::enableTestDevice(true); - auto cpp_headsets = headsetcontrol::discover(); + auto cpp_headsets = headsetcontrol::discover(); hsc_headset_t* c_headsets = nullptr; int c_count = hsc_discover(&c_headsets); diff --git a/tests/test_output_formats.cpp b/tests/test_output_formats.cpp index e59bc160..57112f30 100644 --- a/tests/test_output_formats.cpp +++ b/tests/test_output_formats.cpp @@ -70,7 +70,7 @@ class TestFailure : public std::runtime_error { OutputData data; data.name = "HeadsetControl"; data.version = "1.0.0-test"; - data.api_version = "1.4"; + data.api_version = "1.5"; data.hidapi_version = "0.15.0"; DeviceData dev; @@ -87,7 +87,8 @@ class TestFailure : public std::runtime_error { bat.voltage_mv = 3800; dev.battery = bat; - dev.chatmix = 64; + dev.chatmix = 64; + dev.sidetone = SidetoneData { .level = 43, .device_level = 1, .name = "Low" }; data.devices.push_back(dev); @@ -403,6 +404,9 @@ void testFullJsonOutput() ASSERT_CONTAINS(result, "\"battery\": {", "Should have battery object"); ASSERT_CONTAINS(result, "\"level\": 75", "Should have battery level"); ASSERT_CONTAINS(result, "\"chatmix\": 64", "Should have chatmix"); + ASSERT_CONTAINS(result, "\"sidetone\": {", "Should have sidetone object"); + ASSERT_CONTAINS(result, "\"device_level\": 1", "Should have native sidetone level"); + ASSERT_CONTAINS(result, "\"name\": \"Low\"", "Should have sidetone name"); std::cout << " ✓ Full JSON output is correct" << std::endl; } diff --git a/tests/test_runner.cpp b/tests/test_runner.cpp index d927a85f..f9555c89 100644 --- a/tests/test_runner.cpp +++ b/tests/test_runner.cpp @@ -26,6 +26,7 @@ void runAllDeviceRegistryTests(); void runAllStringEscapingTests(); void runAllLibraryApiTests(); void runAllProtocolTests(); +void runAllSteelSeriesSidetoneTests(); } int main() @@ -67,6 +68,8 @@ int main() // Run protocol tests headsetcontrol::testing::runAllProtocolTests(); + headsetcontrol::testing::runAllSteelSeriesSidetoneTests(); + std::cout << "\n====================================================================" << std::endl; std::cout << " All tests passed successfully! " << std::endl; std::cout << "====================================================================" << std::endl; diff --git a/tests/test_steelseries_sidetone.cpp b/tests/test_steelseries_sidetone.cpp new file mode 100644 index 00000000..2ee3ac03 --- /dev/null +++ b/tests/test_steelseries_sidetone.cpp @@ -0,0 +1,248 @@ +#include "devices/steelseries_arctis_nova_7.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace headsetcontrol::testing { + +class SidetoneTestFailure : public std::runtime_error { +public: + explicit SidetoneTestFailure(const std::string& message) + : std::runtime_error(message) + { + } +}; + +#define SIDETONE_ASSERT(condition, message) \ + do { \ + if (!(condition)) \ + throw SidetoneTestFailure(std::string("Assertion failed: ") + (message)); \ + } while (false) + +class SidetoneMockHID final : public HIDInterface { +public: + std::deque>> reads; + std::vector> writes; + bool fail_write = false; + size_t fail_on_write = 0; + + Result write(hid_device*, std::span data) override + { + writes.emplace_back(data.begin(), data.end()); + if (fail_write || (fail_on_write != 0 && writes.size() == fail_on_write)) + return DeviceError::hidError("Simulated HID write error"); + return {}; + } + + Result write(hid_device* handle, std::span data, size_t size) override + { + std::vector padded(size); + std::copy_n(data.begin(), std::min(data.size(), size), padded.begin()); + return write(handle, padded); + } + + Result readTimeout(hid_device*, std::span data, int) override + { + if (reads.empty()) + return DeviceError::timeout("Simulated timeout"); + + auto result = std::move(reads.front()); + reads.pop_front(); + if (!result) + return result.error(); + + const auto& response = *result; + const size_t size = std::min(response.size(), data.size()); + std::copy_n(response.begin(), size, data.begin()); + return size; + } + + Result sendFeatureReport(hid_device* handle, std::span data) override + { + return write(handle, data); + } + + Result sendFeatureReport(hid_device* handle, std::span data, size_t size) override + { + return write(handle, data, size); + } + + Result getFeatureReport(hid_device*, std::span) override + { + return DeviceError::notSupported("Not used by this test"); + } + + Result getInputReport(hid_device*, std::span) override + { + return DeviceError::notSupported("Not used by this test"); + } +}; + +class TestableNova7 final : public SteelSeriesArctisNova7 { +public: + explicit TestableNova7(SidetoneMockHID& hid, uint16_t product_id = 0x227e) + : hid_(hid) + { + setMatchedProductId(product_id); + } + +protected: + HIDInterface& getHIDInterface() const override { return hid_; } + +private: + SidetoneMockHID& hid_; +}; + +void checkLevel(uint8_t raw, uint8_t normalized, std::string_view name) +{ + SidetoneMockHID hid; + TestableNova7 device(hid); + hid.reads.emplace_back(std::vector { 0x20, 0x07, raw, 0x01 }); + + auto result = device.getSidetone(nullptr); + SIDETONE_ASSERT(result.hasValue(), "valid response should succeed"); + SIDETONE_ASSERT(result->current_level == normalized, "normalized level should match"); + SIDETONE_ASSERT(result->device_level == raw, "native level should match"); + SIDETONE_ASSERT(result->level_name == name, "level name should match"); + SIDETONE_ASSERT(hid.writes.size() == 1, "query should write once"); + SIDETONE_ASSERT(hid.writes[0].size() == 64, "query report should be 64 bytes"); + SIDETONE_ASSERT(hid.writes[0][0] == 0x00 && hid.writes[0][1] == 0x20, + "query should use the audio settings command"); +} + +void testSteelSeriesSidetoneLevels() +{ + checkLevel(0, 0, "Off"); + checkLevel(1, 43, "Low"); + checkLevel(2, 85, "Medium"); + checkLevel(3, 128, "High"); +} + +void testSteelSeriesSidetoneValidation() +{ + { + SidetoneMockHID hid; + TestableNova7 device(hid); + hid.reads.emplace_back(std::vector { 0x20, 0x07, 0x01 }); + auto result = device.getSidetone(nullptr); + SIDETONE_ASSERT(result.hasError(), "short response should fail"); + SIDETONE_ASSERT(result.error().code == DeviceError::Code::ProtocolError, + "short response should be a protocol error"); + } + { + SidetoneMockHID hid; + TestableNova7 device(hid); + hid.reads.emplace_back(std::vector { 0x21, 0x07, 0x01, 0x01 }); + auto result = device.getSidetone(nullptr); + SIDETONE_ASSERT(result.hasError(), "unexpected response type should fail"); + SIDETONE_ASSERT(result.error().code == DeviceError::Code::ProtocolError, + "unexpected response type should be a protocol error"); + } + { + SidetoneMockHID hid; + TestableNova7 device(hid); + hid.reads.emplace_back(std::vector { 0x20, 0x07, 0x04, 0x01 }); + auto result = device.getSidetone(nullptr); + SIDETONE_ASSERT(result.hasError(), "invalid sidetone value should fail"); + SIDETONE_ASSERT(result.error().code == DeviceError::Code::ProtocolError, + "invalid sidetone value should be a protocol error"); + } +} + +void testSteelSeriesSidetoneHIDErrors() +{ + { + SidetoneMockHID hid; + TestableNova7 device(hid); + hid.fail_write = true; + auto result = device.getSidetone(nullptr); + SIDETONE_ASSERT(result.hasError(), "write error should be propagated"); + SIDETONE_ASSERT(result.error().code == DeviceError::Code::HIDError, + "write failure should remain a HID error"); + } + { + SidetoneMockHID hid; + TestableNova7 device(hid); + hid.reads.emplace_back(DeviceError::hidError("Simulated HID read error")); + auto result = device.getSidetone(nullptr); + SIDETONE_ASSERT(result.hasError(), "read error should be propagated"); + SIDETONE_ASSERT(result.error().code == DeviceError::Code::HIDError, + "read failure should remain a HID error"); + } + { + SidetoneMockHID hid; + TestableNova7 device(hid); + hid.reads.emplace_back(DeviceError::timeout("Simulated timeout")); + auto result = device.getSidetone(nullptr); + SIDETONE_ASSERT(result.hasError(), "timeout should be propagated"); + SIDETONE_ASSERT(result.error().code == DeviceError::Code::Timeout, + "timeout should remain a timeout"); + } +} + +void testSteelSeriesSidetoneAsyncStatusAndSave() +{ + SidetoneMockHID hid; + TestableNova7 device(hid); + hid.reads.emplace_back(std::vector { 0xb0, 0x00, 0x64, 0x01 }); + hid.reads.emplace_back(std::vector { 0x20, 0x07, 0x01, 0x01 }); + + auto read_result = device.getSidetone(nullptr); + SIDETONE_ASSERT(read_result.hasValue() && read_result->current_level == 43, + "asynchronous status report should be skipped"); + + hid.writes.clear(); + auto set_result = device.setSidetone(nullptr, 43); + SIDETONE_ASSERT(set_result.hasValue(), "setting sidetone should succeed"); + SIDETONE_ASSERT(hid.writes.size() == 2, "Gen 2 setting should be followed by save"); + SIDETONE_ASSERT(hid.writes[0][0] == 0x00 && hid.writes[0][1] == 0x39 + && hid.writes[0][2] == 0x01, + "set command should contain the discrete level"); + SIDETONE_ASSERT(hid.writes[1][0] == 0x00 && hid.writes[1][1] == 0x09, + "Gen 2 should use the confirmed save command"); + + SidetoneMockHID legacy_hid; + TestableNova7 legacy_device(legacy_hid, 0x2202); + auto legacy_result = legacy_device.setSidetone(nullptr, 43); + SIDETONE_ASSERT(legacy_result.hasValue(), "legacy Nova 7 setting should succeed"); + SIDETONE_ASSERT(legacy_hid.writes.size() == 1, + "other Nova 7 product IDs must not receive the Gen 2 save command"); + + SIDETONE_ASSERT((device.getCapabilities() & B(CAP_SIDETONE_STATUS)) != 0, + "Gen 2 should advertise sidetone status"); + SIDETONE_ASSERT((legacy_device.getCapabilities() & B(CAP_SIDETONE_STATUS)) == 0, + "other Nova 7 product IDs must not advertise sidetone status"); + + auto unsupported_result = legacy_device.getSidetone(nullptr); + SIDETONE_ASSERT(unsupported_result.hasError(), + "sidetone reading should be rejected for unverified product IDs"); + SIDETONE_ASSERT(unsupported_result.error().code == DeviceError::Code::NotSupported, + "unverified product IDs should return not supported"); + + SidetoneMockHID save_failure_hid; + TestableNova7 save_failure_device(save_failure_hid); + save_failure_hid.fail_on_write = 2; + auto save_failure_result = save_failure_device.setSidetone(nullptr, 43); + SIDETONE_ASSERT(save_failure_result.hasError(), "save write failure should be propagated"); + SIDETONE_ASSERT(save_failure_result.error().code == DeviceError::Code::HIDError, + "save write failure should remain a HID error"); +} + +void runAllSteelSeriesSidetoneTests() +{ + std::cout << "\n=== SteelSeries Sidetone Tests ===" << std::endl; + testSteelSeriesSidetoneLevels(); + testSteelSeriesSidetoneValidation(); + testSteelSeriesSidetoneHIDErrors(); + testSteelSeriesSidetoneAsyncStatusAndSave(); + std::cout << " SteelSeries sidetone tests passed" << std::endl; +} + +} // namespace headsetcontrol::testing