diff --git a/AGENTS.md b/AGENTS.md index 6a3cc26..d9cda74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,8 +176,10 @@ Capabilities are enumerated in `lib/device.hpp` via the `CAPABILITIES_XLIST` mac - `CAP_BT_WHEN_POWERED_ON` — Bluetooth-on-power-on behavior - `CAP_BT_CALL_VOLUME` - `CAP_NOISE_FILTER` +- `CAP_SIDETONE_STATUS` — Read the current sidetone level +- `CAP_LIGHT_COLOR` — Set the light color (`LightColorSettings`), implies lights on -When adding a capability, update both `CAPABILITIES_XLIST` and the descriptor/handler tables (see Data-Driven Feature System below). +When adding a capability, append it at the end of `CAPABILITIES_XLIST` (never mid-list — the values are C ABI), mirror it in `hsc_capability_t` in `lib/headsetcontrol_c.h`, and update the descriptor/handler tables (see Data-Driven Feature System below). ### Data-Driven Feature System diff --git a/README.md b/README.md index 58006bc..490e3ca 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A cross-platform tool to control USB gaming headsets on **Linux**, **macOS**, an - **Sidetone** - Hear your own voice without latency (unlike software loopback) - **Battery Status** - Monitor charge level, voltage, and time remaining -- **LED Control** - Toggle lights on/off +- **LED Control** - Toggle lights on/off and set their color - **Equalizer** - Presets and custom EQ curves (including parametric EQ) - **Inactive Time** - Auto power-off timer - **Chat-Mix** - Game/chat audio balance @@ -152,56 +152,58 @@ 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 | sidetone status | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Logitech ASTRO A50 Gen 5 | All | x | x | | x | | x | | | | | x | | | | | | x | | -| 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 | | | | | | | | | | | -| Corsair Headset Device | All | x | x | x | x | | | | | | | | | | | | | | | -| Corsair Wireless V2 Headset Device | All | x | x | | | x | | | | | | | | | | | | | | -| Corsair Virtuoso XT/SE | All | | 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 GameBuds | 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 II Wireless (Kingston) | All | x | x | | | x | | | | | | | | | | | | | | -| HyperX Cloud 3 | All | 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 | | | | | -| Jabra Link 390 (paired headset) | L/M | x | x | | x | x | | x | | | | | | | x | | | | x | -| Jabra Evolve2 65 Flex (USB) | L/M | x | x | | x | x | | x | | | | | | | x | | | | x | -| Sony INZONE Buds | All | | x | | | | | | | | | | | | | | | | | -| Sony INZONE H5 | All | x | x | | | | x | | | | | | | x | | | | | | -| MCHOSE X9 Wireless | L/W | | x | | | | | | | | | | | | | | | | | -| HeadsetControl Test device | All | x | 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 | light color | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Logitech ASTRO A50 Gen 5 | All | x | x | | x | | x | | | | | x | | | | | | x | | | +| 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 | | | | | | | | | | | | +| Corsair Headset Device | All | x | x | x | x | | | | | | | | | | | | | | | | +| Corsair Wireless V2 Headset Device | All | x | x | | | x | | | | | | | | | | | | | | | +| Corsair Virtuoso XT/SE | All | x | x | | 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 GameBuds | 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 II Wireless (Kingston) | All | x | x | | | x | | | | | | | | | | | | | | | +| HyperX Cloud 3 | All | 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 | | | | | | +| Jabra Link 390 (paired headset) | L/M | x | x | | x | x | | x | | | | | | | x | | | | x | | +| Jabra Evolve2 65 Flex (USB) | L/M | x | x | | x | x | | x | | | | | | | x | | | | x | | +| Sony INZONE Buds | All | | x | | | | | | | | | | | | | | | | | | +| Sony INZONE H5 | All | x | x | | | | x | | | | | | | x | | | | | | | +| MCHOSE X9 Wireless | L/W | | x | | | | | | | | | | | | | | | | | | +| HeadsetControl Test device | All | x | 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 \* Only available on some product variants of that device. Sidetone status reading, for instance, is 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. +> **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 other than the XT/SE) expose sidetone via ALSA mixer instead. + +> **Note:** On the Corsair Virtuoso XT/SE, `--light-color` is temporary: the headset returns to its own lighting effect about a minute after the last command. `-l 1` brings its own effect back straight away. ## Usage @@ -224,6 +226,12 @@ headsetcontrol -s # Turn off LEDs headsetcontrol -l 0 +# Set the LED color, which also turns them on (applies to every LED zone) +headsetcontrol --light-color ff8000 + +# -l and --light-color together: the color is applied last, so it wins +headsetcontrol -l 0 --light-color '#00ff00' + # Set auto-off timer (minutes, 0 = disabled) headsetcontrol -i 30 diff --git a/cli/main.cpp b/cli/main.cpp index 20daaa2..e7faa04 100644 --- a/cli/main.cpp +++ b/cli/main.cpp @@ -159,6 +159,7 @@ struct Options { // Complex settings std::optional equalizer; std::optional parametric_equalizer; + std::optional light_color; // Helper [[nodiscard]] bool hasDeviceFilter() const @@ -232,6 +233,15 @@ std::optional configureParser(cli::ArgumentParser& parser, Opti 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)") + .long_custom("light-color", cli::ArgRequirement::Required, [&opts](std::optional arg) -> std::optional { + if (!arg) + return cli::ParseError { "requires a color", "light-color" }; + auto color = headsetcontrol::parse_light_color(*arg); + if (!color) { + return cli::ParseError { "format: RRGGBB or #RRGGBB", "light-color" }; + } + opts.light_color = *color; + return std::nullopt; }, "Set light color, which also turns the lights on", "RRGGBB") .toggle('v', "voice-prompt", opts.voice_prompts_enabled, "Turn voice prompts off (0) or on (1)") .value('i', "inactive-time", opts.inactive_time, uint8_t(0), uint8_t(90), "Set inactive time in minutes", "MINUTES") .flag('m', "chatmix", opts.request_chatmix, "Get chat-mix level") @@ -586,6 +596,8 @@ FeatureResult convertToFeatureResult(const headsetcontrol::FeatureOutput& output result.sidetone_level_name = output.sidetone->level_name; } + result.light_color = output.light_color; + return result; } @@ -893,6 +905,7 @@ namespace help { sections.push_back({ "LIGHTS & AUDIO CUES", {} }); sections.back() .add('l', "light", getValueHint(CAP_LIGHTS), "RGB/LED lights off/on", CAP_LIGHTS) + .add("light-color", getValueHint(CAP_LIGHT_COLOR), "Set light color; turns lights on, and wins over -l", CAP_LIGHT_COLOR) .add('v', "voice-prompt", getValueHint(CAP_VOICE_PROMPTS), "Voice prompts off/on", CAP_VOICE_PROMPTS) .add('n', "notificate", getValueHint(CAP_NOTIFICATION_SOUND), "Play notification sound", CAP_NOTIFICATION_SOUND); @@ -988,6 +1001,7 @@ struct FeatureParamStorage { // Store copies of complex settings to avoid const_cast EqualizerSettings equalizer_settings; ParametricEqualizerSettings parametric_eq_settings; + LightColorSettings light_color_settings; void updateFrom(const Options& opts) { @@ -1025,6 +1039,8 @@ struct FeatureParamStorage { equalizer_settings = *opts.equalizer; if (opts.parametric_equalizer.has_value()) parametric_eq_settings = *opts.parametric_equalizer; + if (opts.light_color.has_value()) + light_color_settings = *opts.light_color; } }; @@ -1055,7 +1071,10 @@ void initializeFeatureRequests(std::vector& devices, const Opt { CAP_VOLUME_LIMITER, CAPABILITYTYPE_ACTION, g_feature_params.volume_limiter_val, opts.volume_limiter_enabled.has_value(), {} }, { CAP_BT_WHEN_POWERED_ON, CAPABILITYTYPE_ACTION, g_feature_params.bt_power_val, opts.bt_when_powered_on.has_value(), {} }, { CAP_BT_CALL_VOLUME, CAPABILITYTYPE_ACTION, g_feature_params.bt_call_vol_val, opts.bt_call_volume.has_value(), {} }, - { CAP_NOISE_FILTER, CAPABILITYTYPE_ACTION, g_feature_params.noise_filter_val, opts.noise_filter.has_value(), {} } + { CAP_NOISE_FILTER, CAPABILITYTYPE_ACTION, g_feature_params.noise_filter_val, opts.noise_filter.has_value(), {} }, + // Last on purpose: requests run in this order, so with both -l and + // --light-color on one command line the color is applied after -l and wins. + { CAP_LIGHT_COLOR, CAPABILITYTYPE_ACTION, opts.light_color.has_value() ? FeatureParam { g_feature_params.light_color_settings } : FeatureParam { std::monostate {} }, opts.light_color.has_value(), {} } }; for (auto& dev : devices) { diff --git a/cli/output/output.cpp b/cli/output/output.cpp index 574186e..180ba66 100644 --- a/cli/output/output.cpp +++ b/cli/output/output.cpp @@ -26,7 +26,9 @@ using namespace headsetcontrol::serializers; // 1.5: an invocation that performs an action no longer reports info it was not // explicitly asked for, and structured output gained an additive "sidetone" // field for devices that support reading it back. -constexpr std::string_view API_VERSION = "1.5"; +// 1.6: action entries gained an additive "color" field ("#rrggbb") for +// CAP_LIGHT_COLOR, whose "value" packs 0xRRGGBB and is reported even for black. +constexpr std::string_view API_VERSION = "1.6"; constexpr std::string_view APP_NAME = "HeadsetControl"; // ============================================================================ @@ -98,6 +100,10 @@ void processActionResult(const FeatureRequest& req, DeviceData& dev, std::string action.status = req.result.status == FEATURE_SUCCESS ? STATUS_SUCCESS : STATUS_FAILURE; action.value = req.result.value; action.error_message = req.result.message; + if (req.result.light_color) { + const auto& c = *req.result.light_color; + action.color = std::format("#{:02x}{:02x}{:02x}", c.r, c.g, c.b); + } dev.actions.push_back(std::move(action)); } @@ -251,8 +257,10 @@ void outputYaml(const OutputData& data) s.pushIndent(1); // Align subsequent keys with "capability" after "- " s.write("device", action.device); s.write("status", statusToString(action.status)); - if (action.value > 0) + if (action.hasValue()) s.write("value", action.value); + if (action.color) + s.write("color", *action.color); if (!action.error_message.empty()) s.write("error_message", action.error_message); s.popIndent(1); @@ -357,9 +365,12 @@ void outputEnv(const OutputData& data) s.write(prefix + "_CAPABILITY", action.capability); s.write(prefix + "_DEVICE", action.device); s.write(prefix + "_STATUS", statusToString(action.status)); - if (action.value > 0) { + if (action.hasValue()) { s.write(prefix + "_VALUE", action.value); } + if (action.color) { + s.write(prefix + "_COLOR", *action.color); + } if (!action.error_message.empty()) { s.write(prefix + "_ERROR_MESSAGE", action.error_message); } diff --git a/cli/output/output_data.hpp b/cli/output/output_data.hpp index d1e6dd4..5905488 100644 --- a/cli/output/output_data.hpp +++ b/cli/output/output_data.hpp @@ -84,17 +84,33 @@ struct ActionData { std::string device; Status status = STATUS_SUCCESS; int value = 0; + std::optional color; // "#rrggbb", only for CAP_LIGHT_COLOR std::string error_message; + /** + * @brief Whether value carries information worth printing + * + * Most actions report 0 when they have nothing to say (the equalizer always + * does) and -1 on failure, so only positive values are shown. A color is the + * exception: black packs to 0 and is still the value that was set. + */ + [[nodiscard]] bool hasValue() const + { + return value > 0 || (color.has_value() && status == STATUS_SUCCESS); + } + void serialize(Serializer& s) const { s.beginObject(""); s.write("capability", capability); s.write("device", device); s.write("status", statusToString(status)); - if (value > 0) { + if (hasValue()) { s.write("value", value); } + if (color) { + s.write("color", *color); + } if (!error_message.empty()) { s.write("error_message", error_message); } diff --git a/docs/ADDING_A_CAPABILITY.md b/docs/ADDING_A_CAPABILITY.md index 5af0254..7918553 100644 --- a/docs/ADDING_A_CAPABILITY.md +++ b/docs/ADDING_A_CAPABILITY.md @@ -22,6 +22,7 @@ A capability is a feature like sidetone, battery status, or LED control. Adding | `lib/capability_descriptors.hpp` | CLI metadata (flags, description, validation) | | `lib/result_types.hpp` | Result struct for the feature | | `lib/devices/hid_device.hpp` | Virtual method in base class | +| `lib/headsetcontrol_c.h` | Mirror the new value in `hsc_capability_t` | | `lib/feature_handlers.hpp` | Handler registration | | `cli/main.cpp` | CLI argument parsing | | `lib/devices/*.hpp` | Device implementations | @@ -42,6 +43,8 @@ Add a single line to `CAPABILITIES_XLIST`. The enum, string name, and short char That's it! The enum value `CAP_YOUR_FEATURE` and all string conversion functions are generated from this single line. +**New capabilities are appended at the end - never inserted mid-list.** The enum values are part of the C ABI: `hsc_capability_t` in `lib/headsetcontrol_c.h` mirrors them by value, so inserting an entry shifts every capability after it for anyone linked against the library. Add the matching `HSC_CAP_YOUR_FEATURE` with the next value and bump `HSC_NUM_CAPABILITIES`; `headsetcontrol_c.cpp` has `static_assert`s that fail the build if the two drift apart. The `CAPABILITY_DESCRIPTORS` array is indexed by the enum, so its new entry goes at the end too. + ### 2. Add Descriptor (`lib/capability_descriptors.hpp`) Find the `CAPABILITY_DESCRIPTORS` array and add your capability: @@ -68,6 +71,8 @@ inline constexpr std::array CAPABILITY_D - `CAPABILITYTYPE_ACTION` - Takes a parameter (sidetone, lights, inactive time) - `CAPABILITYTYPE_INFO` - Query only, no parameter (battery, chatmix) +**Parameter types:** an `int` in `FeatureParam` is only for a scalar in a range, validated through `min_value`/`max_value`. Anything else - a color, a list of bands - gets its own typed struct added to the `FeatureParam` variant in `lib/device.hpp`, with `min_value`/`max_value` left as `std::nullopt`. See `LightColorSettings` and `EqualizerSettings`. + ### 3. Add Result Type (`lib/result_types.hpp`) ```cpp diff --git a/docs/ADDING_A_DEVICE.md b/docs/ADDING_A_DEVICE.md index c4954c4..486310a 100644 --- a/docs/ADDING_A_DEVICE.md +++ b/docs/ADDING_A_DEVICE.md @@ -382,6 +382,9 @@ return makeCapabilityDetail(0xffc0, 0x1, 3); | `CAP_VOLUME_LIMITER` | Action | Volume limiter toggle | | `CAP_BT_WHEN_POWERED_ON` | Action | Bluetooth auto-connect | | `CAP_BT_CALL_VOLUME` | Action | Bluetooth call volume | +| `CAP_NOISE_FILTER` | Action | Microphone noise filter level | +| `CAP_SIDETONE_STATUS` | Info | Read the current sidetone level | +| `CAP_LIGHT_COLOR` | Action | Set the light color (implies lights on) | ## Example: Complete Device Implementation diff --git a/docs/LIBRARY_USAGE.md b/docs/LIBRARY_USAGE.md index 13b8405..e5750af 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.5", + "api_version": "1.6", "device_count": 1, "devices": [ { @@ -403,6 +403,11 @@ if (headset.supports(CAP_LIGHTS)) { headset.setLights(true); } +// Light color - applies to every zone and turns the lights on +if (headset.supports(CAP_LIGHT_COLOR)) { + headset.setLightColor({ .r = 0xff, .g = 0x80, .b = 0x00 }); +} + // Voice prompts on/off if (headset.supports(CAP_VOICE_PROMPTS)) { headset.setVoicePrompts(false); @@ -724,6 +729,9 @@ hsc_set_sidetone(headset, 64, &sidetone_result); // result param is optional (N hsc_set_lights(headset, true); // on hsc_set_lights(headset, false); // off +// Light color (r, g, b) - applies to every zone and turns the lights on +hsc_set_light_color(headset, 0xff, 0x80, 0x00); + // Inactive time (minutes, 0 = disabled) hsc_inactive_time_t time_result; hsc_set_inactive_time(headset, 30, &time_result); @@ -788,6 +796,9 @@ HSC_CAP_MICROPHONE_VOLUME HSC_CAP_VOLUME_LIMITER HSC_CAP_BT_WHEN_POWERED_ON HSC_CAP_BT_CALL_VOLUME +HSC_CAP_NOISE_FILTER +HSC_CAP_SIDETONE_STATUS +HSC_CAP_LIGHT_COLOR ``` ## Compiling C Programs @@ -864,6 +875,9 @@ class Capability(IntEnum): VOLUME_LIMITER = 13 BT_WHEN_POWERED_ON = 14 BT_CALL_VOLUME = 15 + NOISE_FILTER = 16 + SIDETONE_STATUS = 17 + LIGHT_COLOR = 18 # Battery status @@ -943,6 +957,9 @@ _lib.hsc_set_sidetone.restype = c_int _lib.hsc_set_lights.argtypes = [c_void_p, c_bool] _lib.hsc_set_lights.restype = c_int +_lib.hsc_set_light_color.argtypes = [c_void_p, c_uint8, c_uint8, c_uint8] +_lib.hsc_set_light_color.restype = c_int + _lib.hsc_set_inactive_time.argtypes = [c_void_p, c_uint8, c_void_p] _lib.hsc_set_inactive_time.restype = c_int @@ -1017,6 +1034,12 @@ class Headset: return False return _lib.hsc_set_lights(self._handle, enabled) == Result.OK + def set_light_color(self, r: int, g: int, b: int) -> bool: + """Set the light color, which also turns the lights on. Returns True on success.""" + if not self.supports(Capability.LIGHT_COLOR): + return False + return _lib.hsc_set_light_color(self._handle, r, g, b) == Result.OK + def set_inactive_time(self, minutes: int) -> bool: """Set auto power-off time (0 = disabled). Returns True on success.""" if not self.supports(Capability.INACTIVE_TIME): diff --git a/lib/capability_descriptors.hpp b/lib/capability_descriptors.hpp index 6d2fa2a..c60a40c 100644 --- a/lib/capability_descriptors.hpp +++ b/lib/capability_descriptors.hpp @@ -241,6 +241,17 @@ inline constexpr std::array CAPABILITY_D .min_value = std::nullopt, .max_value = std::nullopt, .value_hint = "" }, + + // CAP_LIGHT_COLOR + { + .cap = CAP_LIGHT_COLOR, + .type = CAPABILITYTYPE_ACTION, + .name = "light-color", + .short_flag = "", + .description = "Set the light color (implies lights on)", + .min_value = std::nullopt, + .max_value = std::nullopt, + .value_hint = "" }, } }; /** diff --git a/lib/device.hpp b/lib/device.hpp index a0d76af..d1ad324 100644 --- a/lib/device.hpp +++ b/lib/device.hpp @@ -56,7 +56,8 @@ extern int hsc_device_timeout; 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') + X(CAP_SIDETONE_STATUS, "sidetone status", '\0') \ + X(CAP_LIGHT_COLOR, "light color", '\0') /** @brief A list of all features settable/queryable for headsets * @@ -183,6 +184,17 @@ constexpr auto FEATURE_DEVICE_FAILED_OPEN = FeatureStatus::DeviceFailedOpen; constexpr auto FEATURE_INFO = FeatureStatus::Info; constexpr auto FEATURE_NOT_PROCESSED = FeatureStatus::NotProcessed; +/** @brief Color to set the lights to + * + * Applies to every LED zone the device has. Black (all zero) is a valid color + * that a device may treat as switching the lights off. + */ +struct LightColorSettings { + uint8_t r = 0; + uint8_t g = 0; + uint8_t b = 0; +}; + struct FeatureResult { FeatureStatus status = FeatureStatus::NotProcessed; /// Can hold battery level, error codes, or special status codes @@ -198,6 +210,8 @@ struct FeatureResult { std::optional battery_time_to_empty_min; std::optional sidetone_device_level; std::optional sidetone_level_name; + // Color that was set (only populated for CAP_LIGHT_COLOR) + std::optional light_color; }; // FeatureRequest is defined after EqualizerSettings and ParametricEqualizerSettings @@ -273,8 +287,11 @@ using parametric_equalizer_band = ParametricEqualizerBand; * - int: Simple integer parameters (sidetone level, lights on/off, etc.) * - EqualizerSettings: For CAP_EQUALIZER * - ParametricEqualizerSettings: For CAP_PARAMETRIC_EQUALIZER + * - LightColorSettings: For CAP_LIGHT_COLOR + * + * int is only for a scalar in a range; anything else gets its own typed struct. */ -using FeatureParam = std::variant; +using FeatureParam = std::variant; /** @brief Represents a pending feature request */ diff --git a/lib/devices/corsair_virtuoso_xt.hpp b/lib/devices/corsair_virtuoso_xt.hpp index 96dd6b7..592ea9a 100644 --- a/lib/devices/corsair_virtuoso_xt.hpp +++ b/lib/devices/corsair_virtuoso_xt.hpp @@ -2,8 +2,12 @@ #include "../result_types.hpp" #include "../utility.hpp" #include "corsair_device.hpp" +#include "device.hpp" #include #include +#include +#include +#include #include using namespace std::string_view_literals; @@ -13,29 +17,71 @@ namespace headsetcontrol { /** * @brief Corsair Virtuoso XT / SE (Wireless + Wired) * - * Protocol reverse-engineered via hidraw probing on Linux. + * These headsets speak Corsair's "Bragi" property protocol, the same one used by + * the newer devices in corsair_void_v2w.hpp, but framed on HID report 0x02 of the + * vendor collection (Usage-Page 0xff42) rather than on an unnumbered report. * - * Battery request: send 0x02 0x00 on interface 3 (Usage-Page 0xff42) - * Response format (64 bytes): - * [0] = 0x01 (report ID) - * [1] = status flags (0xf0 = normal, TBD for charging) - * [2] = battery percentage (0-100) - * [3+] = zeros (unused) + * Request layout (64 bytes, zero padded): + * [0] = 0x02 report ID of the vendor OUT report + * [1] = target 0x09 = headset behind a receiver, 0x08 = this device + * [2] = command 0x01 = SET, 0x02 = GET + * [3] = property ID + * [4] = 0x00 + * [5..] = little-endian value (SET only) * - * Volume events are broadcast unsolicited: - * [0] = 0x0E - * [1] = 0x00 (down), 0x01 (up), 0x02 (fast up) + * Reply layout (64 bytes, report ID 0x01): + * [0] = 0x01 report ID of the vendor IN report + * [1] = 0x01 reply relayed from the headset (0x00 = from this device) + * [2] = command echo + * [3] = status 0x00 = ok, 0x05 = no such property, 0x09 = write refused + * [4..] = little-endian value + * + * Writes are refused (status 0x09) unless the headset has been switched into + * software mode (property 0x03 = 2). Settings written that way persist after + * switching back, so they are bracketed by a scope guard that hands the headset + * straight back to hardware mode instead of parking it in software mode. + * + * Lights are switched with the brightness property (0x02) rather than by painting + * a frame. Brightness is the headset's own persisted setting and gates whatever + * effect it is running, so 0 turns the LEDs off and full brightness brings back + * the user's own effect. On the XT this was tested on, brightness 0 survived a + * power cycle. + * + * A light color is not a property but a frame, pushed through the protocol's + * open/write/close handle sequence. Three things set it apart from every other + * write, all checked on that XT: + * - Brightness gates a painted frame too, so a frame painted while the lights + * are off stays dark. Setting a color raises brightness, since it implies on. + * - Handing the headset back to hardware mode replaces the frame with the + * headset's own effect straight away, so a color write stays in software + * mode. That also means -l 1 after a color brings the user's effect back. + * - The color is therefore temporary. Once nothing is talking to it, the headset + * drops back to hardware mode on its own - between 45 and 90 seconds after + * the last command on that XT - and its own effect returns. Vendor software + * gets a lasting color by staying connected. No property or readable resource + * stores a hardware-mode color: a sweep of every 16-bit property and resource + * ID found only resources 0x14 and 0x2a as candidates, and neither can be read + * back, so nothing here writes to them. + * + * The device also broadcasts unsolicited volume events on report 0x0e, which have + * to be skipped when looking for a reply. + * + * Note that plugging the USB-C cable into a Virtuoso XT does not just charge it: + * the headset re-enumerates as the wired product ID, and the receiver then reports + * that no headset is attached. * * Virtuoso XT - Wireless Product ID: 0x0a64 (receiver), Wired Product ID: 0x0a62 * Virtuoso SE - Wireless Product ID: 0x0a3e (receiver), Wired Product ID: 0x0a3d */ class CorsairVirtuosoXT : public CorsairDevice { public: + static constexpr uint16_t PID_XT_WIRELESS = 0x0a64; // Wireless receiver (Virtuoso XT) + static constexpr uint16_t PID_XT_WIRED = 0x0a62; // Wired USB (Virtuoso XT) + static constexpr uint16_t PID_SE_WIRELESS = 0x0a3e; // Wireless receiver (Virtuoso SE) + static constexpr uint16_t PID_SE_WIRED = 0x0a3d; // Wired USB (Virtuoso SE) + static constexpr std::array SUPPORTED_PRODUCT_IDS { - 0x0a64, // Wireless receiver (Virtuoso XT) - 0x0a62, // Wired USB (Virtuoso XT) - 0x0a3e, // Wireless receiver (Virtuoso SE / Slipstream) - 0x0a3d // Wired USB (Virtuoso SE) + PID_XT_WIRELESS, PID_XT_WIRED, PID_SE_WIRELESS, PID_SE_WIRED }; std::vector getProductIds() const override @@ -50,86 +96,588 @@ class CorsairVirtuosoXT : public CorsairDevice { constexpr int getCapabilities() const override { - return B(CAP_BATTERY_STATUS); + return B(CAP_BATTERY_STATUS) | B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_LIGHTS) + | B(CAP_LIGHT_COLOR); } - constexpr capability_detail getCapabilityDetail(enum capabilities cap) const override + constexpr capability_detail + getCapabilityDetail([[maybe_unused]] enum capabilities cap) const override { - switch (cap) { - case CAP_BATTERY_STATUS: - // Interface 3, Usage-Page 0xff42, Usage-ID 0x0001 - return { .usagepage = 0xff42, .usageid = 0x1, .interface_id = 3 }; - default: - return HIDDevice::getCapabilityDetail(cap); - } + // Interface 3, Usage-Page 0xff42, Usage-ID 0x0001 + return { .usagepage = 0xff42, .usageid = 0x1, .interface_id = 3 }; } Result getBattery(hid_device* device_handle) override { auto start_time = std::chrono::steady_clock::now(); - // Send battery status request: 0x02 0x00 - std::array request { 0x02, 0x00 }; - if (auto result = writeHID(device_handle, request); !result) { - return result.error(); + // Resolving the target already reads the battery level. Neither that nor + // the charge state needs software mode, which keeps the headset from + // producing an audible pop just to report its battery. + auto resolved = resolveTarget(device_handle); + if (!resolved) { + return resolved.error(); } + const uint32_t level = resolved->battery_level; - // Read the battery report (ID 0x01), skipping any unsolicited volume - // events (ID 0x0e) the device broadcasts and that may be queued ahead - // of the reply. - std::array response {}; - int attempt = 0; - while (true) { - auto read_result = readHIDTimeout(device_handle, response, hsc_device_timeout); - if (!read_result) { - return read_result.error(); - } - if (response[0] == 0x01) { - break; - } - if (++attempt >= MAX_READ_ATTEMPTS) { - return DeviceError::protocolError( - std::format("Unexpected report ID: 0x{:02x}", response[0])); - } + // The level is reported in tenths of a percent. + if (level > BATTERY_LEVEL_MAX) { + return DeviceError::protocolError(std::format("Battery level out of range: {}", level)); + } + + // Charge state is a separate property; treat it as advisory so that a + // firmware which does not implement it still yields a usable level. + auto status = BATTERY_AVAILABLE; + if (auto charge_state = readProperty(device_handle, resolved->target, PROP_BATTERY_STATUS); + charge_state && *charge_state == CHARGE_STATE_CHARGING) { + status = BATTERY_CHARGING; } auto duration = std::chrono::duration_cast( std::chrono::steady_clock::now() - start_time); - // Byte 1: status flags - // 0xf0 = normal / discharging (confirmed via observation) - // 0x00 = headset offline / not connected to receiver - // Other values TBD (charging state not yet reverse-engineered) - const uint8_t status_byte = response[1]; - const uint8_t battery_level = response[2]; + return BatteryResult { + .level_percent = static_cast(level / 10), + .status = status, + .mic_status = MICROPHONE_UNKNOWN, + .query_duration = duration, + }; + } + + Result setSidetone(hid_device* device_handle, uint8_t level) override + { + // The headset stores the sidetone volume as 0-1000 in steps of 10. + const uint16_t mapped_level + = map(level, 0, 128, SIDETONE_DEVICE_MIN, SIDETONE_DEVICE_MAX); + const auto sidetone_value + = static_cast(round_to_multiples(mapped_level, 10)); + + auto resolved = resolveTarget(device_handle); + if (!resolved) { + return resolved.error(); + } + const uint8_t target = resolved->target; - if (status_byte == 0x00) { - return DeviceError::deviceOffline("Headset not connected to receiver"); + if (auto result = writeProperty(device_handle, target, PROP_MODE, MODE_SOFTWARE); + !result) { + return result.error(); } + SoftwareModeGuard guard { *this, device_handle, target }; - if (battery_level > 100) { - return DeviceError::protocolError( - std::format("Battery percentage out of range: {}", battery_level)); + // Level 0 switches sidetone off outright rather than turning it down. + if (auto result = writeProperty(device_handle, target, PROP_SIDETONE_ENABLED, + level == 0 ? 0 : 1); + !result) { + return result.error(); } - return BatteryResult { - .level_percent = static_cast(battery_level), - .status = BATTERY_AVAILABLE, - .mic_status = MICROPHONE_UNKNOWN, - .raw_data = std::vector(response.begin(), response.end()), - .query_duration = duration, + if (level > 0) { + if (auto result + = writeProperty(device_handle, target, PROP_SIDETONE_VOLUME, sidetone_value); + !result) { + return result.error(); + } + } + + return SidetoneResult { + .current_level = level, + .min_level = 0, + .max_level = 128, + .device_min = 0, + // The native range is 0-1000, which does not fit the single byte this + // struct exposes, so report it as a percentage instead. + .device_max = 100, + }; + } + + Result setInactiveTime(hid_device* device_handle, uint8_t minutes) override + { + if (minutes > MAX_INACTIVE_MINUTES) { + minutes = MAX_INACTIVE_MINUTES; + } + + auto resolved = resolveTarget(device_handle); + if (!resolved) { + return resolved.error(); + } + const uint8_t target = resolved->target; + + if (auto result = writeProperty(device_handle, target, PROP_MODE, MODE_SOFTWARE); + !result) { + return result.error(); + } + SoftwareModeGuard guard { *this, device_handle, target }; + + if (auto result + = writeProperty(device_handle, target, PROP_SLEEP_ENABLED, minutes == 0 ? 0 : 1); + !result) { + return result.error(); + } + + // The timeout itself is stored in milliseconds. + if (minutes > 0) { + const uint32_t timeout_ms = static_cast(minutes) * 60U * 1000U; + if (auto result + = writeProperty(device_handle, target, PROP_SLEEP_TIMEOUT, timeout_ms); + !result) { + return result.error(); + } + } + + return InactiveTimeResult { + .minutes = minutes, + .min_minutes = 0, + .max_minutes = MAX_INACTIVE_MINUTES, }; } + Result setLights(hid_device* device_handle, bool on) override + { + auto resolved = resolveTarget(device_handle); + if (!resolved) { + return resolved.error(); + } + const uint8_t target = resolved->target; + + if (auto result = writeProperty(device_handle, target, PROP_MODE, MODE_SOFTWARE); + !result) { + return result.error(); + } + SoftwareModeGuard guard { *this, device_handle, target }; + + // Brightness is the headset's own persisted setting rather than a frame we + // paint, so it gates whatever effect the headset is running: 0 switches the + // LEDs off, full brightness brings back the user's own effect. + if (auto result + = writeProperty(device_handle, target, PROP_BRIGHTNESS, on ? BRIGHTNESS_MAX : 0); + !result) { + return result.error(); + } + + return LightsResult { .enabled = on }; + } + + Result setLightColor( + hid_device* device_handle, const LightColorSettings& color) override + { + auto resolved = resolveTarget(device_handle); + if (!resolved) { + return resolved.error(); + } + const uint8_t target = resolved->target; + + if (auto result = writeProperty(device_handle, target, PROP_MODE, MODE_SOFTWARE); + !result) { + return result.error(); + } + // Restoring hardware mode would swap the frame for the headset's own effect + // before anyone saw it, so a successful write stays in software mode. A + // failed one has nothing on screen worth keeping and is handed back. + SoftwareModeGuard restore_on_failure { *this, device_handle, target }; + + // Brightness gates the frame as well, and a color implies the lights are on. + if (auto result = writeProperty(device_handle, target, PROP_BRIGHTNESS, BRIGHTNESS_MAX); + !result) { + return result.error(); + } + + if (auto result = writeLighting(device_handle, target, color); !result) { + return result.error(); + } + + restore_on_failure.dismiss(); + return LightColorResult { .color = color }; + } + Result getCapabilityInfo(enum capabilities cap) override { - return HIDDevice::getCapabilityInfo(cap); + auto info = HIDDevice::getCapabilityInfo(cap); + if (!info) { + return info; + } + + switch (cap) { + case CAP_SIDETONE: + info->parameter + = CapabilityInfo::RangeParam { .min = 0, .max = 128, .step = 1, .units = "level" }; + break; + + case CAP_INACTIVE_TIME: + info->parameter = CapabilityInfo::RangeParam { + .min = 0, .max = MAX_INACTIVE_MINUTES, .step = 1, .units = "minutes" + }; + break; + + default: + break; + } + + return info; } private: - // Reports to skip (e.g. unsolicited volume events) before giving up on the - // battery reply + static constexpr uint8_t REPORT_ID_OUT = 0x02; + static constexpr uint8_t REPORT_ID_IN = 0x01; + + // A wireless receiver relays commands to the headset paired with it, whereas a + // wired headset answers for itself. Addressing the wrong one is not reported as + // an error - the device simply stays silent. + static constexpr uint8_t TARGET_HEADSET = 0x09; + static constexpr uint8_t TARGET_SELF = 0x08; + static constexpr uint8_t REPLY_FROM_HEADSET = 0x01; + static constexpr uint8_t REPLY_FROM_SELF = 0x00; + + static constexpr uint8_t BRAGI_SET = 0x01; + static constexpr uint8_t BRAGI_GET = 0x02; + static constexpr uint8_t BRAGI_CLOSE_HANDLE = 0x05; + static constexpr uint8_t BRAGI_WRITE_DATA = 0x06; + static constexpr uint8_t BRAGI_OPEN_HANDLE = 0x0d; + + // Handle and resource numbering follow ckb-next, which drives the LEDs on + // Corsair's other Bragi devices the same way. + static constexpr uint8_t LIGHTING_HANDLE = 0x00; + static constexpr uint8_t LIGHTING_RESOURCE = 0x01; + // The firmware takes a frame for three LEDs, one color channel at a time: + // every red byte, then every green byte, then every blue byte. + static constexpr uint8_t LIGHTING_ZONES = 3; + static constexpr uint8_t LIGHTING_PAYLOAD_SIZE = LIGHTING_ZONES * 3; + static constexpr size_t LIGHTING_PAYLOAD_OFFSET = 8; + + static constexpr uint8_t STATUS_OK = 0x00; + static constexpr uint8_t STATUS_NO_PROPERTY = 0x05; + // Returned when opening a handle that is already open + static constexpr uint8_t STATUS_HANDLE_OPEN = 0x03; + + static constexpr uint8_t PROP_BRIGHTNESS = 0x02; + static constexpr uint8_t PROP_MODE = 0x03; + static constexpr uint8_t PROP_SLEEP_ENABLED = 0x0d; + static constexpr uint8_t PROP_SLEEP_TIMEOUT = 0x0e; + static constexpr uint8_t PROP_BATTERY_LEVEL = 0x0f; + static constexpr uint8_t PROP_BATTERY_STATUS = 0x10; + static constexpr uint8_t PROP_SIDETONE_ENABLED = 0x46; + static constexpr uint8_t PROP_SIDETONE_VOLUME = 0x47; + + static constexpr uint16_t MODE_HARDWARE = 1; + static constexpr uint16_t MODE_SOFTWARE = 2; + + static constexpr uint32_t CHARGE_STATE_CHARGING = 1; + + static constexpr uint16_t BRIGHTNESS_MAX = 1000; + static constexpr uint32_t BATTERY_LEVEL_MAX = 1000; + static constexpr uint16_t SIDETONE_DEVICE_MIN = 0; + static constexpr uint16_t SIDETONE_DEVICE_MAX = 1000; + static constexpr uint8_t MAX_INACTIVE_MINUTES = 90; + + static constexpr size_t MSG_SIZE = 64; + // Unsolicited reports (volume events) to skip before giving up on a reply static constexpr int MAX_READ_ATTEMPTS = 8; + // Long enough for a reply from a device that is listening, short enough that + // asking the wrong target does not stall the command. A headset slower than + // this is reported offline. + static constexpr int TARGET_PROBE_TIMEOUT_MS = 300; + // Upper bound on queued reports thrown away before a request, so a device + // that never stops sending cannot stall it + static constexpr int MAX_STALE_REPORTS = 32; + + /** + * @brief Restores hardware mode when leaving the scope of a write + * + * Settings written in software mode persist - sidetone, the sleep timer and + * brightness all survived a power cycle on the XT this was tested on - so there + * is nothing to gain by keeping the headset there once the write is done, + * including a write that failed part way through. + * + * That XT also dropped back to hardware mode by itself after a few minutes + * without host traffic, but that was measured on one unit and nothing here + * relies on it. + */ + class SoftwareModeGuard { + public: + SoftwareModeGuard(CorsairVirtuosoXT& device, hid_device* device_handle, uint8_t target) + : device_(device) + , device_handle_(device_handle) + , target_(target) + { + } + + SoftwareModeGuard(const SoftwareModeGuard&) = delete; + SoftwareModeGuard& operator=(const SoftwareModeGuard&) = delete; + SoftwareModeGuard(SoftwareModeGuard&&) = delete; + SoftwareModeGuard& operator=(SoftwareModeGuard&&) = delete; + + ~SoftwareModeGuard() + { + if (dismissed_) { + return; + } + // Best effort; there is nothing useful to do if the restore fails. + static_cast( + device_.writeProperty(device_handle_, target_, PROP_MODE, MODE_HARDWARE)); + } + + /// Leave the headset in software mode after all + void dismiss() noexcept { dismissed_ = true; } + + private: + CorsairVirtuosoXT& device_; + hid_device* device_handle_; + uint8_t target_; + bool dismissed_ = false; + }; + + static constexpr uint8_t replySourceFor(uint8_t target) + { + return target == TARGET_SELF ? REPLY_FROM_SELF : REPLY_FROM_HEADSET; + } + + /** + * @brief Work out whether this device answers for itself or relays to a headset + * + * The registry hands out a single instance per device class and only records + * the product ID it last matched on, so that ID is a hint rather than an + * answer: it goes stale as soon as a wireless receiver and a wired headset are + * plugged in at the same time. The hint is therefore tried first, but only + * accepted once the device has answered on it. + * + * The battery level is used as the probe because a receiver answers identity + * properties for itself even when no headset is paired with it, and would + * otherwise look like a valid target. The level is handed back so that + * getBattery() does not have to ask for it a second time. + */ + struct ResolvedTarget { + uint8_t target; + uint32_t battery_level; + }; + + [[nodiscard]] Result resolveTarget(hid_device* device_handle) + { + const auto product_id = getMatchedProductId(); + const bool wired = product_id == PID_XT_WIRED || product_id == PID_SE_WIRED; + + const uint8_t hinted = wired ? TARGET_SELF : TARGET_HEADSET; + const uint8_t alternate = wired ? TARGET_HEADSET : TARGET_SELF; + + for (const uint8_t candidate : { hinted, alternate }) { + auto level + = readProperty(device_handle, candidate, PROP_BATTERY_LEVEL, TARGET_PROBE_TIMEOUT_MS); + if (level) { + return ResolvedTarget { .target = candidate, .battery_level = *level }; + } + + // Silence means nothing is listening on that target, and "no such + // property" is a receiver answering for itself with no headset behind + // it. Anything else - a failed HID write, an unexpected status - is a + // real fault, and reporting it as offline would hide it. + const auto code = level.error().code; + if (code != DeviceError::Code::DeviceOffline && code != DeviceError::Code::NotSupported) { + return level.error(); + } + } + + return DeviceError::deviceOffline("Headset not connected or powered off"); + } + + /** + * @brief Read a property + * + * @return The little-endian value, or an error if the device stays silent or + * the property is unknown to this firmware + */ + [[nodiscard]] Result readProperty( + hid_device* device_handle, uint8_t target, uint8_t property, int timeout_ms = 0) + { + const std::array request { REPORT_ID_OUT, target, BRAGI_GET, property }; + auto response = transact(device_handle, target, request, BRAGI_GET, + timeout_ms == 0 ? hsc_device_timeout : timeout_ms); + if (!response) { + return response.error(); + } + + const auto& data = *response; + if (data[3] == STATUS_NO_PROPERTY) { + return DeviceError::notSupported( + std::format("Property 0x{:02x} not supported by this firmware", property)); + } + if (data[3] != STATUS_OK) { + return DeviceError::protocolError( + std::format("Read of property 0x{:02x} failed with status 0x{:02x}", property, + data[3])); + } + + return static_cast(data[4]) | (static_cast(data[5]) << 8) + | (static_cast(data[6]) << 16) | (static_cast(data[7]) << 24); + } + + /** + * @brief Write a property + * + * Requires software mode; outside it the headset answers with status 0x09. + */ + [[nodiscard]] Result writeProperty( + hid_device* device_handle, uint8_t target, uint8_t property, uint32_t value) + { + const std::array request { REPORT_ID_OUT, target, BRAGI_SET, property, + 0x00, static_cast(value & 0xFF), static_cast((value >> 8) & 0xFF), + static_cast((value >> 16) & 0xFF), static_cast((value >> 24) & 0xFF) }; + auto response = transact(device_handle, target, request, BRAGI_SET, hsc_device_timeout); + if (!response) { + return response.error(); + } + + if ((*response)[3] != STATUS_OK) { + return DeviceError::protocolError( + std::format("Write of property 0x{:02x} rejected with status 0x{:02x}", property, + (*response)[3])); + } + return {}; + } + + /** + * @brief Paint every LED zone one color + * + * The headset has to already be in software mode for the frame to apply. + */ + [[nodiscard]] Result writeLighting( + hid_device* device_handle, uint8_t target, const LightColorSettings& color) + { + const std::array open_request { REPORT_ID_OUT, target, + BRAGI_OPEN_HANDLE, LIGHTING_HANDLE, LIGHTING_RESOURCE, 0x00 }; + const std::array close_request { REPORT_ID_OUT, target, + BRAGI_CLOSE_HANDLE, 0x01, LIGHTING_HANDLE }; + + auto opened = sendHandleCommand(device_handle, target, open_request, BRAGI_OPEN_HANDLE); + // A transfer that was never closed - a run that failed between open and + // close, say - leaves the handle open, and the headset then refuses to + // open it again. Close it and retry once, as ckb-next does. + if (opened && *opened == STATUS_HANDLE_OPEN) { + if (auto closed = sendHandleCommand( + device_handle, target, close_request, BRAGI_CLOSE_HANDLE); + !closed) { + return closed.error(); + } + opened = sendHandleCommand(device_handle, target, open_request, BRAGI_OPEN_HANDLE); + } + if (auto result = expectOk(opened, "open the lighting handle"); !result) { + return result.error(); + } + + std::array write_request { REPORT_ID_OUT, target, BRAGI_WRITE_DATA, + LIGHTING_HANDLE, LIGHTING_PAYLOAD_SIZE, 0x00, 0x00, 0x00 }; + for (uint8_t zone = 0; zone < LIGHTING_ZONES; ++zone) { + write_request[LIGHTING_PAYLOAD_OFFSET + zone] = color.r; + write_request[LIGHTING_PAYLOAD_OFFSET + LIGHTING_ZONES + zone] = color.g; + write_request[LIGHTING_PAYLOAD_OFFSET + (2 * LIGHTING_ZONES) + zone] = color.b; + } + auto written = expectOk( + sendHandleCommand(device_handle, target, write_request, BRAGI_WRITE_DATA), + "write the lighting frame"); + + // Close even if the frame was rejected, so this run does not leave the + // handle open for the next one. + auto closed = expectOk( + sendHandleCommand(device_handle, target, close_request, BRAGI_CLOSE_HANDLE), + "close the lighting handle"); + + if (!written) { + return written.error(); + } + return closed; + } + + /** + * @brief Send one step of a handle transfer + * + * @return The status byte from the reply, left for the caller to interpret + */ + [[nodiscard]] Result sendHandleCommand(hid_device* device_handle, uint8_t target, + std::span request, uint8_t command) + { + auto response = transact(device_handle, target, request, command, hsc_device_timeout); + if (!response) { + return response.error(); + } + return (*response)[3]; + } + + [[nodiscard]] static Result expectOk(const Result& status, std::string_view step) + { + if (!status) { + return status.error(); + } + if (*status != STATUS_OK) { + return DeviceError::protocolError( + std::format("Failed to {} (status 0x{:02x})", step, *status)); + } + return {}; + } + + /** + * @brief Send a request and wait for its reply + * + * Replies carry no property or handle ID, only who sent them and the command + * they answer. A reply that arrives after its request has already timed out + * would otherwise be taken by the next request of the same kind, so anything + * still queued is thrown away first. + */ + [[nodiscard]] Result> transact(hid_device* device_handle, + uint8_t target, std::span request, uint8_t command, int timeout_ms) + { + if (auto result = discardStaleReports(device_handle); !result) { + return result.error(); + } + if (auto result = writeHID(device_handle, request, MSG_SIZE); !result) { + return result.error(); + } + return readReply(device_handle, target, command, timeout_ms); + } + + /** + * @brief Throw away reports already waiting on the handle, without blocking + */ + [[nodiscard]] Result discardStaleReports(hid_device* device_handle) + { + std::array stale {}; + for (int discarded = 0; discarded < MAX_STALE_REPORTS; ++discarded) { + auto result = readHIDTimeout(device_handle, stale, 0); + if (!result) { + // An empty queue shows up as a timeout on a non-blocking read. + if (result.error().code == DeviceError::Code::Timeout) { + return {}; + } + return result.error(); + } + } + return {}; + } + + /** + * @brief Read the reply to a command, skipping unsolicited reports + * + * Volume events arrive on report 0x0e, and when a receiver is in play it also + * answers some commands on its own behalf, so both are filtered out here. + */ + [[nodiscard]] Result> readReply( + hid_device* device_handle, uint8_t target, uint8_t command, int timeout_ms) + { + std::array response {}; + for (int attempt = 0; attempt < MAX_READ_ATTEMPTS; ++attempt) { + if (auto result = readHIDTimeout(device_handle, response, timeout_ms); !result) { + // A headset that is powered off or out of range never answers. + if (result.error().code == DeviceError::Code::Timeout) { + return DeviceError::deviceOffline("Headset not connected or powered off"); + } + return result.error(); + } + + if (response[0] == REPORT_ID_IN && response[1] == replySourceFor(target) + && response[2] == command) { + return response; + } + } + + return DeviceError::protocolError(std::format( + "No reply to command 0x{:02x} after {} reports", command, MAX_READ_ATTEMPTS)); + } }; } // namespace headsetcontrol diff --git a/lib/devices/headsetcontrol_test.hpp b/lib/devices/headsetcontrol_test.hpp index b7edf32..0543a9a 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_SIDETONE_STATUS); + | B(CAP_NOISE_FILTER) | B(CAP_SIDETONE_STATUS) | B(CAP_LIGHT_COLOR); } std::optional getEqualizerInfo() const override @@ -187,6 +187,12 @@ class HeadsetControlTest : public HIDDevice { return LightsResult { .enabled = on }; } + Result setLightColor([[maybe_unused]] hid_device* device_handle, + const LightColorSettings& color) override + { + return LightColorResult { .color = color }; + } + Result setInactiveTime([[maybe_unused]] hid_device* device_handle, uint8_t minutes) override { return InactiveTimeResult { diff --git a/lib/devices/hid_device.hpp b/lib/devices/hid_device.hpp index 326948c..91d173c 100644 --- a/lib/devices/hid_device.hpp +++ b/lib/devices/hid_device.hpp @@ -158,6 +158,18 @@ class HIDDevice { return DeviceError::notSupported("Device does not support lights control"); } + /** + * @brief Set the color of the lights + * + * Implies the lights are on. Takes a struct rather than three bytes so that + * per-zone colors can be added later without changing the signature. + */ + virtual Result setLightColor( + hid_device* /*device_handle*/, const LightColorSettings& /*color*/) + { + return DeviceError::notSupported("Device does not support setting the light color"); + } + /** * @brief Set inactive time with rich result */ diff --git a/lib/feature_handlers.hpp b/lib/feature_handlers.hpp index e92d443..582958d 100644 --- a/lib/feature_handlers.hpp +++ b/lib/feature_handlers.hpp @@ -22,6 +22,7 @@ struct FeatureOutput { std::optional battery; // Extended battery info std::optional chatmix; // Extended chatmix info std::optional sidetone; // Extended sidetone info + std::optional light_color; // Color that was set static FeatureOutput success(int val, std::string msg = "") { @@ -54,6 +55,15 @@ struct FeatureOutput { .sidetone = s }; } + + static FeatureOutput fromLightColor(const LightColorResult& c) + { + return { + .value = (c.color.r << 16) | (c.color.g << 8) | c.color.b, + .message = "", + .light_color = c.color + }; + } }; /** @@ -195,6 +205,11 @@ namespace detail { return std::get(p); } + inline const LightColorSettings& getLightColor(const FeatureParam& p) + { + return std::get(p); + } + } // namespace detail // ============================================================================ @@ -348,6 +363,14 @@ inline void FeatureHandlerRegistry::registerAllHandlers() return r.error(); return FeatureOutput::fromSidetone(r.value()); }); + + // CAP_LIGHT_COLOR + registerHandler(CAP_LIGHT_COLOR, [](HIDDevice* dev, hid_device* h, const FeatureParam& p) -> Result { + auto r = dev->setLightColor(h, getLightColor(p)); + if (r.hasError()) + return r.error(); + return FeatureOutput::fromLightColor(r.value()); + }); } } // namespace headsetcontrol diff --git a/lib/headsetcontrol.cpp b/lib/headsetcontrol.cpp index f1eab1b..4937cba 100644 --- a/lib/headsetcontrol.cpp +++ b/lib/headsetcontrol.cpp @@ -326,6 +326,11 @@ Result Headset::setLights(bool enabled) HEADSET_FEATURE_IMPL(CAP_LIGHTS, setLights, enabled); } +Result Headset::setLightColor(const LightColorSettings& color) +{ + HEADSET_FEATURE_IMPL(CAP_LIGHT_COLOR, setLightColor, color); +} + Result Headset::setVoicePrompts(bool enabled) { HEADSET_FEATURE_IMPL(CAP_VOICE_PROMPTS, setVoicePrompts, enabled); diff --git a/lib/headsetcontrol.hpp b/lib/headsetcontrol.hpp index 55322d5..48f99c8 100644 --- a/lib/headsetcontrol.hpp +++ b/lib/headsetcontrol.hpp @@ -227,6 +227,16 @@ class Headset { */ [[nodiscard]] Result setLights(bool enabled); + /** + * @brief Set the color of the lights, which also turns them on + * + * Applies to every LED zone. Black is a valid color and may switch the + * lights off on some devices. + * + * @param color Color to set + */ + [[nodiscard]] Result setLightColor(const LightColorSettings& color); + /** * @brief Set voice prompts on/off * @param enabled Enable/disable voice prompts diff --git a/lib/headsetcontrol_c.cpp b/lib/headsetcontrol_c.cpp index 9fe1e33..d0184e2 100644 --- a/lib/headsetcontrol_c.cpp +++ b/lib/headsetcontrol_c.cpp @@ -5,6 +5,16 @@ #include #include +// hsc_capability_t mirrors the C++ capabilities enum by value, and nothing else +// ties the two together - which is how a capability once went missing from the +// C header and shifted every value after it. Check each entry and the count. +#define X(id, name, short_char) \ + static_assert(static_cast(HSC_##id) == static_cast(id), "hsc_capability_t out of sync with capabilities: " #id); +CAPABILITIES_XLIST +#undef X +static_assert(static_cast(HSC_NUM_CAPABILITIES) == static_cast(NUM_CAPABILITIES), + "hsc_capability_t out of sync with capabilities: count differs"); + // ============================================================================ // Internal State // ============================================================================ @@ -477,6 +487,17 @@ hsc_result_t hsc_set_lights(hsc_headset_t headset, bool enabled) return result ? HSC_RESULT_OK : toErrorCode(result.error()); } +hsc_result_t hsc_set_light_color(hsc_headset_t headset, uint8_t r, uint8_t g, uint8_t b) +{ + if (!headset) { + return HSC_RESULT_INVALID_PARAM; + } + + auto result = static_cast(headset)->headset.setLightColor( + LightColorSettings { .r = r, .g = g, .b = b }); + return result ? HSC_RESULT_OK : toErrorCode(result.error()); +} + hsc_result_t hsc_set_voice_prompts(hsc_headset_t headset, bool enabled) { if (!headset) { diff --git a/lib/headsetcontrol_c.h b/lib/headsetcontrol_c.h index 8340799..bdde450 100644 --- a/lib/headsetcontrol_c.h +++ b/lib/headsetcontrol_c.h @@ -101,7 +101,8 @@ typedef enum { HSC_CAP_BT_CALL_VOLUME = 15, HSC_CAP_NOISE_FILTER = 16, HSC_CAP_SIDETONE_STATUS = 17, - HSC_NUM_CAPABILITIES = 18, + HSC_CAP_LIGHT_COLOR = 18, + HSC_NUM_CAPABILITIES = 19, } hsc_capability_t; /* ============================================================================ @@ -423,6 +424,20 @@ HSC_API hsc_result_t hsc_set_rotate_to_mute(hsc_headset_t headset, bool enabled) */ HSC_API hsc_result_t hsc_set_lights(hsc_headset_t headset, bool enabled); +/** + * @brief Set the color of the lights, which also turns them on + * + * Applies to every LED zone. Black (0, 0, 0) is a valid color and may switch + * the lights off on some devices. + * + * @param headset Headset handle + * @param r Red component (0-255) + * @param g Green component (0-255) + * @param b Blue component (0-255) + * @return HSC_RESULT_OK on success, negative error code on failure + */ +HSC_API hsc_result_t hsc_set_light_color(hsc_headset_t headset, uint8_t r, uint8_t g, uint8_t b); + /** * @brief Set voice prompts on/off * diff --git a/lib/result_types.hpp b/lib/result_types.hpp index 79a5c15..a230f4d 100644 --- a/lib/result_types.hpp +++ b/lib/result_types.hpp @@ -196,6 +196,13 @@ struct LightsResult { std::optional mode; // Mode if supported (e.g., "breathing", "static", "off") }; +/** + * @brief Light color information + */ +struct LightColorResult { + LightColorSettings color; // Color applied to the lights +}; + /** * @brief Inactive time information */ diff --git a/lib/utility.cpp b/lib/utility.cpp index 7693213..7b3f85f 100644 --- a/lib/utility.cpp +++ b/lib/utility.cpp @@ -2,6 +2,7 @@ #include "device.hpp" #include +#include #include #include #include @@ -228,6 +229,33 @@ std::optional> parse_two_ids(std::string_view input, int def return std::make_pair(static_cast(values[0]), static_cast(values[1])); } +std::optional parse_light_color(std::string_view input) +{ + if (input.starts_with('#')) { + input.remove_prefix(1); + } + if (input.size() != 6) { + return std::nullopt; + } + + // from_chars would accept a sign or stop early, so check every digit first. + if (!std::ranges::all_of(input, [](char c) { return std::isxdigit(static_cast(c)) != 0; })) { + return std::nullopt; + } + + uint32_t rgb = 0; + auto [ptr, ec] = std::from_chars(input.data(), input.data() + input.size(), rgb, 16); + if (ec != std::errc() || ptr != input.data() + input.size()) { + return std::nullopt; + } + + return LightColorSettings { + .r = static_cast((rgb >> 16) & 0xFF), + .g = static_cast((rgb >> 8) & 0xFF), + .b = static_cast(rgb & 0xFF), + }; +} + /** * Converts a filter type string to the corresponding EqualizerFilterType enum * Returns nullopt if the string does not match a known filter type. diff --git a/lib/utility.hpp b/lib/utility.hpp index 40678c8..1c6d5f1 100644 --- a/lib/utility.hpp +++ b/lib/utility.hpp @@ -12,6 +12,7 @@ // Forward declarations struct ParametricEqualizerSettings; +struct LightColorSettings; namespace headsetcontrol { @@ -101,6 +102,16 @@ ParametricEqualizerSettings parse_parametric_equalizer_settings(std::string_view */ std::optional> parse_two_ids(std::string_view input, int default_base = 10); +/** + * @brief Parse a color written as "RRGGBB" or "#RRGGBB" + * + * Exactly six hex digits, in either case. "000000" is a valid color. + * + * @param input string to parse + * @return the color if successful, nullopt otherwise + */ +std::optional parse_light_color(std::string_view input); + /** * @brief Cross-platform sleep for milliseconds */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 88ce025..aeff20c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,6 +15,7 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/test_library_api.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test_protocols.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test_steelseries_sidetone.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/test_corsair_virtuoso.cpp ) # Export to parent scope diff --git a/tests/test_cli_output.cpp b/tests/test_cli_output.cpp index 27be287..22fa91d 100644 --- a/tests/test_cli_output.cpp +++ b/tests/test_cli_output.cpp @@ -443,6 +443,51 @@ void testCliSidetoneStatusOutputs() std::cout << " ✓ Sidetone status output is correct" << std::endl; } +void testCliLightColorOutputs() +{ + std::cout << " Testing light color in all output formats..." << std::endl; + + const std::string base = HEADSETCONTROL_EXE " --test-device -d 0xf00b:0xa00c --light-color 000000 -o "; + + // Black packs to 0, and every format must still report it + std::string json = exec((base + "json 2>&1").c_str()); + ASSERT_CONTAINS(json, "\"capability\": \"CAP_LIGHT_COLOR\"", "JSON should have the light color action"); + ASSERT_CONTAINS(json, "\"value\": 0", "JSON should keep black's value"); + ASSERT_CONTAINS(json, "\"color\": \"#000000\"", "JSON should have the color"); + + std::string yaml = exec((base + "yaml 2>&1").c_str()); + ASSERT_CONTAINS(yaml, "value: 0", "YAML should keep black's value"); + ASSERT_CONTAINS(yaml, "color: \"#000000\"", "YAML should have the color"); + + std::string env = exec((base + "env 2>&1").c_str()); + ASSERT_CONTAINS(env, "ACTION_0_VALUE=0", "ENV should keep black's value"); + ASSERT_CONTAINS(env, "ACTION_0_COLOR=\"#000000\"", "ENV should have the color"); + + std::string standard = exec(HEADSETCONTROL_EXE " --test-device -d 0xf00b:0xa00c --light-color ff8000 2>&1"); + ASSERT_CONTAINS(standard, "Successfully set light color!", "standard output should confirm"); + + // With -l on the same command line, the color is applied last + std::string both = exec(HEADSETCONTROL_EXE " --test-device -d 0xf00b:0xa00c -l 0 --light-color \"#FF8000\" -o json 2>&1"); + ASSERT_CONTAINS(both, "\"value\": 16744448", "value should pack 0xRRGGBB"); + ASSERT_CONTAINS(both, "\"color\": \"#ff8000\"", "color should be normalised to lower case"); + const auto lights_at = both.find("\"CAP_LIGHTS\""); + const auto color_at = both.find("\"CAP_LIGHT_COLOR\""); + ASSERT_TRUE(lights_at != std::string::npos && color_at != std::string::npos && lights_at < color_at, + "-l should run before --light-color"); + + // -l on its own keeps its existing output: no value for 0 + std::string lights_off = exec(HEADSETCONTROL_EXE " --test-device -d 0xf00b:0xa00c -l 0 -o json 2>&1"); + ASSERT_NOT_CONTAINS(lights_off, "\"value\"", "-l 0 should still omit value"); + + std::string invalid = exec(HEADSETCONTROL_EXE " --test-device --light-color 12345 2>&1"); + ASSERT_CONTAINS(invalid, "format: RRGGBB or #RRGGBB", "invalid color should be rejected"); + + std::string help = exec(HEADSETCONTROL_EXE " --help-all 2>&1"); + ASSERT_CONTAINS(help, "--light-color ", "help should document --light-color"); + + std::cout << " ✓ Light color output is correct" << std::endl; +} + // ============================================================================ // Short Output Tests // ============================================================================ @@ -578,6 +623,7 @@ void runAllCliOutputTests() runTest("Standard Battery Details", testCliStandardBatteryDetails); runTest("Standard No Args", testCliStandardNoArgs); runTest("Sidetone Status Outputs", testCliSidetoneStatusOutputs); + runTest("Light Color Outputs", testCliLightColorOutputs); std::cout << "\n=== Short Output Tests ===" << std::endl; runTest("Short Output", testCliShortOutput); diff --git a/tests/test_corsair_virtuoso.cpp b/tests/test_corsair_virtuoso.cpp new file mode 100644 index 0000000..f43d4a3 --- /dev/null +++ b/tests/test_corsair_virtuoso.cpp @@ -0,0 +1,274 @@ +#include "devices/corsair_virtuoso_xt.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace headsetcontrol::testing { + +class VirtuosoTestFailure : public std::runtime_error { +public: + explicit VirtuosoTestFailure(const std::string& message) + : std::runtime_error(message) + { + } +}; + +#define VIRTUOSO_ASSERT(condition, message) \ + do { \ + if (!(condition)) \ + throw VirtuosoTestFailure(std::string("Assertion failed: ") + (message)); \ + } while (false) + +/** + * @brief Mock HID interface that knows when each report arrives + * + * A report only becomes readable once a given number of requests have been + * written, which is what separates a stale reply (already queued before a + * request goes out) from the reply to that request. + */ +class VirtuosoMockHID final : public HIDInterface { +public: + struct Report { + size_t after_write; // readable once this many writes have happened + std::vector data; + }; + + std::deque reads; + std::vector> writes; + size_t fail_on_write = 0; // 1-based; 0 means never + + Result write(hid_device*, std::span data) override + { + writes.emplace_back(data.begin(), data.end()); + if (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() || reads.front().after_write > writes.size()) + return DeviceError::timeout("Simulated timeout"); + + const auto report = std::move(reads.front()); + reads.pop_front(); + const size_t size = std::min(report.data.size(), data.size()); + std::copy_n(report.data.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"); + } + + /// Queue a GET/SET reply: [report 0x01][source][command][status][value LE] + void reply(size_t after_write, uint8_t source, uint8_t command, uint8_t status, uint32_t value = 0) + { + reads.push_back({ after_write, + { 0x01, source, command, status, static_cast(value & 0xFF), + static_cast((value >> 8) & 0xFF), static_cast((value >> 16) & 0xFF), + static_cast((value >> 24) & 0xFF) } }); + } +}; + +class TestableVirtuoso final : public CorsairVirtuosoXT { +public: + TestableVirtuoso(VirtuosoMockHID& hid, uint16_t product_id) + : hid_(hid) + { + setMatchedProductId(product_id); + } + +protected: + HIDInterface& getHIDInterface() const override { return hid_; } + +private: + VirtuosoMockHID& hid_; +}; + +constexpr uint16_t PID_WIRELESS = 0x0a64; +constexpr uint16_t PID_WIRED = 0x0a62; +constexpr uint8_t FROM_HEADSET = 0x01; +constexpr uint8_t FROM_SELF = 0x00; +constexpr uint8_t GET = 0x02; +constexpr uint8_t STATUS_OK = 0x00; +constexpr uint8_t STATUS_NO_PROP = 0x05; +constexpr uint32_t CHARGING = 1; +constexpr uint32_t DISCHARGING = 2; + +void testVirtuosoBattery() +{ + std::cout << " Testing battery over both targets..." << std::endl; + + // Wireless: the receiver relays to the headset behind it. + VirtuosoMockHID wireless; + TestableVirtuoso wireless_device(wireless, PID_WIRELESS); + wireless.reply(1, FROM_HEADSET, GET, STATUS_OK, 810); + wireless.reply(2, FROM_HEADSET, GET, STATUS_OK, CHARGING); + auto battery = wireless_device.getBattery(nullptr); + VIRTUOSO_ASSERT(battery.hasValue(), "wireless battery should succeed"); + VIRTUOSO_ASSERT(battery->level_percent == 81, "level is reported in tenths of a percent"); + VIRTUOSO_ASSERT(battery->status == BATTERY_CHARGING, "charge state 1 means charging"); + VIRTUOSO_ASSERT(wireless.writes.size() == 2, "the probed level should not be read a second time"); + VIRTUOSO_ASSERT(wireless.writes[0][1] == 0x09, "wireless requests target the headset"); + + // Wired: the headset answers for itself. + VirtuosoMockHID wired; + TestableVirtuoso wired_device(wired, PID_WIRED); + wired.reply(1, FROM_SELF, GET, STATUS_OK, 1000); + wired.reply(2, FROM_SELF, GET, STATUS_OK, DISCHARGING); + battery = wired_device.getBattery(nullptr); + VIRTUOSO_ASSERT(battery.hasValue(), "wired battery should succeed"); + VIRTUOSO_ASSERT(battery->level_percent == 100 && battery->status == BATTERY_AVAILABLE, + "wired battery should read 100% discharging"); + VIRTUOSO_ASSERT(wired.writes[0][1] == 0x08, "wired requests target the device itself"); + + std::cout << " OK battery over both targets" << std::endl; +} + +void testVirtuosoStaleReplyIsDiscarded() +{ + std::cout << " Testing a late reply is not taken for a new request's..." << std::endl; + + // A charge-state reply left over from an earlier request that timed out is + // already queued before getBattery() sends anything. Replies carry no + // property ID, so without discarding it the level probe would read 1 as the + // battery level. + VirtuosoMockHID hid; + TestableVirtuoso device(hid, PID_WIRELESS); + hid.reply(0, FROM_HEADSET, GET, STATUS_OK, CHARGING); + hid.reply(1, FROM_HEADSET, GET, STATUS_OK, 810); + hid.reply(2, FROM_HEADSET, GET, STATUS_OK, DISCHARGING); + + auto battery = device.getBattery(nullptr); + VIRTUOSO_ASSERT(battery.hasValue(), "battery should succeed"); + VIRTUOSO_ASSERT(battery->level_percent == 81, "the stale reply must not be read as the level"); + VIRTUOSO_ASSERT(battery->status == BATTERY_AVAILABLE, "the charge state must come from its own reply"); + + std::cout << " OK late reply discarded" << std::endl; +} + +void testVirtuosoTargetResolutionErrors() +{ + std::cout << " Testing which probe failures mean \"wrong target\"..." << std::endl; + + // A receiver with its headset off: the headset stays silent, and the + // receiver answers the battery probe for itself with "no such property". + // Both mean nothing is there, so the headset is offline. + VirtuosoMockHID receiver_only; + TestableVirtuoso receiver_device(receiver_only, PID_WIRELESS); + receiver_only.reply(2, FROM_SELF, GET, STATUS_NO_PROP); + auto offline = receiver_device.getBattery(nullptr); + VIRTUOSO_ASSERT(offline.hasError(), "no headset should fail"); + VIRTUOSO_ASSERT(offline.error().code == DeviceError::Code::DeviceOffline, + "a silent headset behind a receiver should be reported offline, not unsupported"); + VIRTUOSO_ASSERT(receiver_only.writes.size() == 2, "both targets should have been probed"); + + // A HID failure is a real fault and must not be dressed up as offline. + VirtuosoMockHID broken; + TestableVirtuoso broken_device(broken, PID_WIRELESS); + broken.fail_on_write = 1; + auto failure = broken_device.getBattery(nullptr); + VIRTUOSO_ASSERT(failure.hasError(), "a failed write should fail"); + VIRTUOSO_ASSERT(failure.error().code == DeviceError::Code::HIDError, + "a HID error during target resolution should be propagated"); + VIRTUOSO_ASSERT(broken.writes.size() == 1, "a HID error should not fall through to the other target"); + + std::cout << " OK target resolution errors" << std::endl; +} + +/// Whether a write is SET mode (property 0x03) to the given value +bool setsMode(const std::vector& write, uint8_t mode) +{ + return write.size() > 5 && write[2] == 0x01 && write[3] == 0x03 && write[5] == mode; +} + +void testVirtuosoLightColorSoftwareMode() +{ + std::cout << " Testing light color leaves software mode only on failure..." << std::endl; + + constexpr uint8_t SET = 0x01, CLOSE = 0x05, WRITE = 0x06, OPEN = 0x0d; + + // Success: probe, mode, brightness, open, frame, close - and the headset + // stays in software mode so the frame remains visible. + VirtuosoMockHID ok; + TestableVirtuoso ok_device(ok, PID_WIRED); + ok.reply(1, FROM_SELF, GET, STATUS_OK, 900); + ok.reply(2, FROM_SELF, SET, STATUS_OK); + ok.reply(3, FROM_SELF, SET, STATUS_OK); + ok.reply(4, FROM_SELF, OPEN, STATUS_OK); + ok.reply(5, FROM_SELF, WRITE, STATUS_OK); + ok.reply(6, FROM_SELF, CLOSE, STATUS_OK); + auto color = ok_device.setLightColor(nullptr, LightColorSettings { .r = 0xff, .g = 0x80, .b = 0x00 }); + VIRTUOSO_ASSERT(color.hasValue(), "a clean color write should succeed"); + VIRTUOSO_ASSERT(ok.writes.size() == 6, "a clean color write takes six requests"); + VIRTUOSO_ASSERT(setsMode(ok.writes[1], 2), "the second request should enter software mode"); + for (const auto& write : ok.writes) { + VIRTUOSO_ASSERT(!setsMode(write, 1), "a successful color write must not restore hardware mode"); + } + const auto& frame = ok.writes[4]; + VIRTUOSO_ASSERT(frame[8] == 0xff && frame[10] == 0xff && frame[11] == 0x80 && frame[14] == 0x00, + "the frame should be planar: three red bytes, then green, then blue"); + + // Failure: the frame write fails, so the handle is still closed and the + // headset is handed back to hardware mode. + VirtuosoMockHID failing; + TestableVirtuoso failing_device(failing, PID_WIRED); + failing.reply(1, FROM_SELF, GET, STATUS_OK, 900); + failing.reply(2, FROM_SELF, SET, STATUS_OK); + failing.reply(3, FROM_SELF, SET, STATUS_OK); + failing.reply(4, FROM_SELF, OPEN, STATUS_OK); + failing.fail_on_write = 5; + failing.reply(6, FROM_SELF, CLOSE, STATUS_OK); + failing.reply(7, FROM_SELF, SET, STATUS_OK); + color = failing_device.setLightColor(nullptr, LightColorSettings { .r = 0xff }); + VIRTUOSO_ASSERT(color.hasError(), "a failed frame write should fail"); + VIRTUOSO_ASSERT(color.error().code == DeviceError::Code::HIDError, "the frame write error should be returned"); + VIRTUOSO_ASSERT(failing.writes.size() == 7, "the handle should be closed and hardware mode restored"); + VIRTUOSO_ASSERT(failing.writes[5][2] == CLOSE, "the handle should be closed after the failed write"); + VIRTUOSO_ASSERT(setsMode(failing.writes.back(), 1), "a failed color write should restore hardware mode"); + + std::cout << " OK light color software mode" << std::endl; +} + +void runAllCorsairVirtuosoTests() +{ + std::cout << "\n=== Corsair Virtuoso Tests ===" << std::endl; + testVirtuosoBattery(); + testVirtuosoStaleReplyIsDiscarded(); + testVirtuosoTargetResolutionErrors(); + testVirtuosoLightColorSoftwareMode(); + std::cout << " Corsair Virtuoso tests passed" << std::endl; +} + +} // namespace headsetcontrol::testing diff --git a/tests/test_library_api.cpp b/tests/test_library_api.cpp index 7f45e3b..5c63472 100644 --- a/tests/test_library_api.cpp +++ b/tests/test_library_api.cpp @@ -268,6 +268,7 @@ void testCNullHandling() 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"); + ASSERT_EQ(HSC_RESULT_INVALID_PARAM, hsc_set_light_color(nullptr, 0, 0, 0), "set_light_color(null) should fail"); std::cout << " OK C API null handling" << std::endl; } @@ -310,6 +311,14 @@ void testCppTestDeviceMode() 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"); + ASSERT_TRUE(headset.supports(CAP_LIGHT_COLOR), "Test device should support light color"); + + // Test light color + auto color = headset.setLightColor(LightColorSettings { .r = 0x12, .g = 0x34, .b = 0x56 }); + ASSERT_TRUE(color.hasValue(), "Light color should return success"); + ASSERT_EQ(0x12, color->color.r, "Red should be echoed"); + ASSERT_EQ(0x34, color->color.g, "Green should be echoed"); + ASSERT_EQ(0x56, color->color.b, "Blue should be echoed"); // Test battery auto battery = headset.getBattery(); @@ -426,6 +435,11 @@ void testCTestDeviceMode() 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"); + ASSERT_TRUE(hsc_supports(headsets[i], HSC_CAP_LIGHT_COLOR), "Should support light color"); + + // Test light color, including black + ASSERT_EQ(HSC_RESULT_OK, hsc_set_light_color(headsets[i], 0xff, 0x80, 0x00), "Light color should succeed"); + ASSERT_EQ(HSC_RESULT_OK, hsc_set_light_color(headsets[i], 0, 0, 0), "Black should succeed"); // Test battery hsc_battery_t battery; diff --git a/tests/test_output_formats.cpp b/tests/test_output_formats.cpp index 57112f3..705bd27 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.5"; + data.api_version = "1.6"; data.hidapi_version = "0.15.0"; DeviceData dev; @@ -453,6 +453,48 @@ void testBatteryDataSerialization() std::cout << " ✓ BatteryData serialization is correct" << std::endl; } +void testActionDataValue() +{ + std::cout << " Testing ActionData value and color..." << std::endl; + + auto serialize = [](const ActionData& action) { + std::ostringstream out; + JsonSerializer s(out); + s.beginDocument(); + s.beginArray("actions"); + action.serialize(s); + s.endArray(); + s.endDocument(); + return out.str(); + }; + + // Black packs to 0 but is still the value that was set + ActionData black; + black.capability = "CAP_LIGHT_COLOR"; + black.value = 0; + black.color = "#000000"; + std::string result = serialize(black); + ASSERT_CONTAINS(result, "\"value\": 0", "Black should keep its value"); + ASSERT_CONTAINS(result, "\"color\": \"#000000\"", "Black should have a color"); + + // Without a color, 0 still means "nothing to report" (e.g. the equalizer) + ActionData plain; + plain.capability = "CAP_EQUALIZER"; + plain.value = 0; + result = serialize(plain); + ASSERT_TRUE(result.find("\"value\"") == std::string::npos, "Zero value without a color should be omitted"); + ASSERT_TRUE(result.find("\"color\"") == std::string::npos, "No color key without a color"); + + // A failed action never reports its value + ActionData failed = black; + failed.status = STATUS_FAILURE; + failed.value = -1; + result = serialize(failed); + ASSERT_TRUE(result.find("\"value\"") == std::string::npos, "Failed action should omit value"); + + std::cout << " ✓ ActionData value and color are correct" << std::endl; +} + // ============================================================================ // Test Runner // ============================================================================ @@ -496,6 +538,7 @@ void runAllOutputFormatTests() std::cout << "\n=== Integration Tests ===" << std::endl; runTest("Full JSON Output", testFullJsonOutput); runTest("BatteryData Serialization", testBatteryDataSerialization); + runTest("ActionData Value", testActionDataValue); std::cout << "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" << std::endl; std::cout << "Output Format Tests: " << passed << " passed, " << failed << " failed" << std::endl; diff --git a/tests/test_runner.cpp b/tests/test_runner.cpp index f9555c8..bc0272c 100644 --- a/tests/test_runner.cpp +++ b/tests/test_runner.cpp @@ -27,6 +27,7 @@ void runAllStringEscapingTests(); void runAllLibraryApiTests(); void runAllProtocolTests(); void runAllSteelSeriesSidetoneTests(); +void runAllCorsairVirtuosoTests(); } int main() @@ -70,6 +71,8 @@ int main() headsetcontrol::testing::runAllSteelSeriesSidetoneTests(); + headsetcontrol::testing::runAllCorsairVirtuosoTests(); + std::cout << "\n====================================================================" << std::endl; std::cout << " All tests passed successfully! " << std::endl; std::cout << "====================================================================" << std::endl; diff --git a/tests/test_utilities.cpp b/tests/test_utilities.cpp index 0f1a586..c29cae8 100644 --- a/tests/test_utilities.cpp +++ b/tests/test_utilities.cpp @@ -518,6 +518,41 @@ void testParseTwoIds() std::cout << " ✓ parse_two_ids works correctly" << std::endl; } +void testParseLightColor() +{ + std::cout << " Testing parse_light_color..." << std::endl; + + auto plain = parse_light_color("ff8000"); + ASSERT_TRUE(plain.has_value(), "Should parse RRGGBB"); + ASSERT_EQ(0xff, plain->r, "Red should be 0xff"); + ASSERT_EQ(0x80, plain->g, "Green should be 0x80"); + ASSERT_EQ(0x00, plain->b, "Blue should be 0x00"); + + auto hashed = parse_light_color("#12AbeF"); + ASSERT_TRUE(hashed.has_value(), "Should parse #RRGGBB in mixed case"); + ASSERT_EQ(0x12, hashed->r, "Red should be 0x12"); + ASSERT_EQ(0xab, hashed->g, "Green should be 0xab"); + ASSERT_EQ(0xef, hashed->b, "Blue should be 0xef"); + + // Black is a real color, not "unset" + auto black = parse_light_color("000000"); + ASSERT_TRUE(black.has_value(), "Black should parse"); + ASSERT_EQ(0, black->r + black->g + black->b, "Black should be all zero"); + + ASSERT_FALSE(parse_light_color("").has_value(), "Empty should fail"); + ASSERT_FALSE(parse_light_color("#").has_value(), "Bare # should fail"); + ASSERT_FALSE(parse_light_color("fff").has_value(), "Shorthand should fail"); + ASSERT_FALSE(parse_light_color("1234567").has_value(), "Seven digits should fail"); + ASSERT_FALSE(parse_light_color("12345G").has_value(), "Non-hex digit should fail"); + ASSERT_FALSE(parse_light_color("0xff00").has_value(), "0x prefix should fail"); + ASSERT_FALSE(parse_light_color("+fffff").has_value(), "Sign should fail"); + ASSERT_FALSE(parse_light_color("-fffff").has_value(), "Negative should fail"); + ASSERT_FALSE(parse_light_color("##ff00ff").has_value(), "Double # should fail"); + ASSERT_FALSE(parse_light_color("ff 000").has_value(), "Embedded space should fail"); + + std::cout << " ✓ parse_light_color works correctly" << std::endl; +} + // ============================================================================ // result_types.hpp Tests // ============================================================================ @@ -830,6 +865,7 @@ void runAllUtilityTests() runTest("parse_float_data", testParseFloatData); runTest("parse_parametric_eq", testParseParametricEqualizerSettings); runTest("parse_two_ids", testParseTwoIds); + runTest("parse_light_color", testParseLightColor); std::cout << "\n=== result_types.hpp Tests ===" << std::endl; runTest("Result success", testResultSuccess);