From d919b53f31cdd49aa3360a9abedb3b1ee9ce038d Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Tue, 5 Aug 2025 18:05:52 +0300 Subject: [PATCH 01/14] Feat: Add sidetone support for SteelSeries Arctis Nova 3P Wireless --- src/device_registry.c | 2 + src/devices/CMakeLists.txt | 2 + .../steelseries_arctis_nova_3p_wireless.c | 71 +++++++++++++++++++ .../steelseries_arctis_nova_3p_wireless.h | 3 + 4 files changed, 78 insertions(+) create mode 100644 src/devices/steelseries_arctis_nova_3p_wireless.c create mode 100644 src/devices/steelseries_arctis_nova_3p_wireless.h diff --git a/src/device_registry.c b/src/device_registry.c index fd92f4f..9aa8f0e 100644 --- a/src/device_registry.c +++ b/src/device_registry.c @@ -22,6 +22,7 @@ #include "devices/steelseries_arctis_7_plus.h" #include "devices/steelseries_arctis_9.h" #include "devices/steelseries_arctis_nova_3.h" +#include "devices/steelseries_arctis_nova_3p_wireless.h" #include "devices/steelseries_arctis_nova_5.h" #include "devices/steelseries_arctis_nova_7.h" #include "devices/steelseries_arctis_nova_pro_wireless.h" @@ -68,6 +69,7 @@ void init_devices() add_device(elo71USB_init); // SteelSeries add_device(arctis_nova_3_init); + add_device(arctis_nova_3p_wireless_init); add_device(arctis_nova_5_init); add_device(arctis_nova_7_init); add_device(arctis_7_plus_init); diff --git a/src/devices/CMakeLists.txt b/src/devices/CMakeLists.txt index c9ba00d..4acca15 100644 --- a/src/devices/CMakeLists.txt +++ b/src/devices/CMakeLists.txt @@ -29,6 +29,8 @@ set(SOURCE_FILES ${SOURCE_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/steelseries_arctis_9.h ${CMAKE_CURRENT_SOURCE_DIR}/steelseries_arctis_nova_3.h ${CMAKE_CURRENT_SOURCE_DIR}/steelseries_arctis_nova_3.c + ${CMAKE_CURRENT_SOURCE_DIR}/steelseries_arctis_nova_3p_wireless.h + ${CMAKE_CURRENT_SOURCE_DIR}/steelseries_arctis_nova_3p_wireless.c ${CMAKE_CURRENT_SOURCE_DIR}/steelseries_arctis_nova_5.h ${CMAKE_CURRENT_SOURCE_DIR}/steelseries_arctis_nova_5.c ${CMAKE_CURRENT_SOURCE_DIR}/steelseries_arctis_nova_7.h diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.c b/src/devices/steelseries_arctis_nova_3p_wireless.c new file mode 100644 index 0000000..66841c3 --- /dev/null +++ b/src/devices/steelseries_arctis_nova_3p_wireless.c @@ -0,0 +1,71 @@ +#include "../device.h" + +#include +#include +#include + +#define MSG_SIZE 64 + +#define ID_ARCTIS_NOVA_3P_WIRELESS 0x2269 + +static struct device device_arctis; + +static const uint16_t PRODUCT_IDS[] = { ID_ARCTIS_NOVA_3P_WIRELESS }; +static const uint8_t SAVE_DATA[MSG_SIZE] = { 0x09 }; // Command to save settings to headset + +static int arctis_nova_3p_wireless_send_sidetone(hid_device* device_handle, uint8_t num); + +void arctis_nova_3p_wireless_init(struct device** device) +{ + device_arctis.idVendor = VENDOR_STEELSERIES; + device_arctis.idProductsSupported = PRODUCT_IDS; + device_arctis.numIdProducts = sizeof(PRODUCT_IDS) / sizeof(PRODUCT_IDS[0]); + + strncpy(device_arctis.device_name, "SteelSeries Arctis Nova 3P Wireless", sizeof(device_arctis.device_name)); + + device_arctis.capabilities = B(CAP_SIDETONE); + // 0xc (3), 0xffc0 (4), 0xff00 (4) + device_arctis.capability_details[CAP_SIDETONE] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 4 }; + + device_arctis.send_sidetone = &arctis_nova_3p_wireless_send_sidetone; + // device_arctis.send_equalizer_preset = &arctis_nova_3_send_equalizer_preset; + // device_arctis.send_equalizer = &arctis_nova_3_send_equalizer; + // device_arctis.send_microphone_mute_led_brightness = &arctis_nova_3_send_microphone_mute_led_brightness; + // device_arctis.send_microphone_volume = &arctis_nova_3_send_microphone_volume; + + *device = &device_arctis; +} + +static int arctis_nova_3p_wireless_send_sidetone(hid_device* device_handle, uint8_t num) +{ + // This headset supports 11 levels of sidetone volume (0x0 to 0xa), + // mapping a full range of 0-128 input values. + if (num < 12) { + num = 0x0; + } else if (num < 24) { + num = 0x1; + } else if (num < 36) { + num = 0x2; + } else if (num < 48) { + num = 0x3; + } else if (num < 60) { + num = 0x4; + } else if (num < 72) { + num = 0x5; + } else if (num < 84) { + num = 0x6; + } else if (num < 96) { + num = 0x7; + } else if (num < 107) { + num = 0x8; + } else if (num < 118) { + num = 0x9; + } else { // num is 118 or greater (up to 128) + num = 0xa; + } + + uint8_t data[MSG_SIZE] = { 0x39, num }; + hid_send_feature_report(device_handle, data, MSG_SIZE); + + return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); +} diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.h b/src/devices/steelseries_arctis_nova_3p_wireless.h new file mode 100644 index 0000000..99bf07d --- /dev/null +++ b/src/devices/steelseries_arctis_nova_3p_wireless.h @@ -0,0 +1,3 @@ +#pragma once + +void arctis_nova_3p_wireless_init(struct device** device); From feb9323848d7acfcff033d4348d51dee94997abe Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Tue, 5 Aug 2025 21:18:30 +0300 Subject: [PATCH 02/14] Feat: Add microphone volume support for SteelSeries Arctis Nova 3P Wireless --- .../steelseries_arctis_nova_3p_wireless.c | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.c b/src/devices/steelseries_arctis_nova_3p_wireless.c index 66841c3..72faf82 100644 --- a/src/devices/steelseries_arctis_nova_3p_wireless.c +++ b/src/devices/steelseries_arctis_nova_3p_wireless.c @@ -14,6 +14,7 @@ static const uint16_t PRODUCT_IDS[] = { ID_ARCTIS_NOVA_3P_WIRELESS }; static const uint8_t SAVE_DATA[MSG_SIZE] = { 0x09 }; // Command to save settings to headset static int arctis_nova_3p_wireless_send_sidetone(hid_device* device_handle, uint8_t num); +static int arctis_nova_3p_wireless_send_microphone_volume(hid_device* device_handle, uint8_t num); void arctis_nova_3p_wireless_init(struct device** device) { @@ -23,15 +24,19 @@ void arctis_nova_3p_wireless_init(struct device** device) strncpy(device_arctis.device_name, "SteelSeries Arctis Nova 3P Wireless", sizeof(device_arctis.device_name)); - device_arctis.capabilities = B(CAP_SIDETONE); + device_arctis.capabilities = B(CAP_SIDETONE) | /* B(CAP_EQUALIZER_PRESET) | B(CAP_EQUALIZER) | B(CAP_MICROPHONE_MUTE_LED_BRIGHTNESS) | */ B(CAP_MICROPHONE_VOLUME); // 0xc (3), 0xffc0 (4), 0xff00 (4) device_arctis.capability_details[CAP_SIDETONE] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 4 }; + // device_arctis.capability_details[CAP_EQUALIZER_PRESET] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 4 }; + // device_arctis.capability_details[CAP_EQUALIZER] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 4 }; + // device_arctis.capability_details[CAP_MICROPHONE_MUTE_LED_BRIGHTNESS] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 4 }; + device_arctis.capability_details[CAP_MICROPHONE_VOLUME] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 4 }; device_arctis.send_sidetone = &arctis_nova_3p_wireless_send_sidetone; // device_arctis.send_equalizer_preset = &arctis_nova_3_send_equalizer_preset; // device_arctis.send_equalizer = &arctis_nova_3_send_equalizer; // device_arctis.send_microphone_mute_led_brightness = &arctis_nova_3_send_microphone_mute_led_brightness; - // device_arctis.send_microphone_volume = &arctis_nova_3_send_microphone_volume; + device_arctis.send_microphone_volume = &arctis_nova_3p_wireless_send_microphone_volume; *device = &device_arctis; } @@ -69,3 +74,44 @@ static int arctis_nova_3p_wireless_send_sidetone(hid_device* device_handle, uint return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); } + +static int arctis_nova_3p_wireless_send_microphone_volume(hid_device* device_handle, uint8_t num) +{ + // This headset only supports 10 levels of microphone volume, but we allow a full range of 0-128 for it. Map the volume to the correct numbers. + if (num < 9) { + num = 0x00; + } else if (num < 18) { + num = 0x01; + } else if (num < 27) { + num = 0x02; + } else if (num < 36) { + num = 0x03; + } else if (num < 45) { + num = 0x04; + } else if (num < 54) { + num = 0x05; + } else if (num < 63) { + num = 0x06; + } else if (num < 72) { + num = 0x07; + } else if (num < 81) { + num = 0x08; + } else if (num < 90) { + num = 0x09; + } else if (num < 99) { + num = 0x0a; + } else if (num < 108) { + num = 0x0b; + } else if (num < 117) { + num = 0x0c; + } else if (num < 126) { + num = 0x0d; + } else { + num = 0x0e; + } + + uint8_t volume[MSG_SIZE] = { 0x37, num }; + hid_send_feature_report(device_handle, volume, MSG_SIZE); + + return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); +} From 4c46f578b779201a6f874272e7487c9e06ec24a4 Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Fri, 8 Aug 2025 15:35:14 +0300 Subject: [PATCH 03/14] Feat: Add inactivity threshold support for SteelSeries Arctis Nova 3P Wireless --- .../steelseries_arctis_nova_3p_wireless.c | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.c b/src/devices/steelseries_arctis_nova_3p_wireless.c index 72faf82..38635a7 100644 --- a/src/devices/steelseries_arctis_nova_3p_wireless.c +++ b/src/devices/steelseries_arctis_nova_3p_wireless.c @@ -4,9 +4,9 @@ #include #include -#define MSG_SIZE 64 +enum { MSG_SIZE = 64 }; -#define ID_ARCTIS_NOVA_3P_WIRELESS 0x2269 +enum { ID_ARCTIS_NOVA_3P_WIRELESS = 0x2269 }; static struct device device_arctis; @@ -15,6 +15,7 @@ static const uint8_t SAVE_DATA[MSG_SIZE] = { 0x09 }; // Command to save settings static int arctis_nova_3p_wireless_send_sidetone(hid_device* device_handle, uint8_t num); static int arctis_nova_3p_wireless_send_microphone_volume(hid_device* device_handle, uint8_t num); +static int arctis_nova_3p_wireless_send_inactive_time(hid_device* device_handle, uint8_t num); void arctis_nova_3p_wireless_init(struct device** device) { @@ -24,18 +25,9 @@ void arctis_nova_3p_wireless_init(struct device** device) strncpy(device_arctis.device_name, "SteelSeries Arctis Nova 3P Wireless", sizeof(device_arctis.device_name)); - device_arctis.capabilities = B(CAP_SIDETONE) | /* B(CAP_EQUALIZER_PRESET) | B(CAP_EQUALIZER) | B(CAP_MICROPHONE_MUTE_LED_BRIGHTNESS) | */ B(CAP_MICROPHONE_VOLUME); - // 0xc (3), 0xffc0 (4), 0xff00 (4) - device_arctis.capability_details[CAP_SIDETONE] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 4 }; - // device_arctis.capability_details[CAP_EQUALIZER_PRESET] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 4 }; - // device_arctis.capability_details[CAP_EQUALIZER] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 4 }; - // device_arctis.capability_details[CAP_MICROPHONE_MUTE_LED_BRIGHTNESS] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 4 }; - device_arctis.capability_details[CAP_MICROPHONE_VOLUME] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 4 }; - - device_arctis.send_sidetone = &arctis_nova_3p_wireless_send_sidetone; - // device_arctis.send_equalizer_preset = &arctis_nova_3_send_equalizer_preset; - // device_arctis.send_equalizer = &arctis_nova_3_send_equalizer; - // device_arctis.send_microphone_mute_led_brightness = &arctis_nova_3_send_microphone_mute_led_brightness; + device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME); + device_arctis.send_sidetone = &arctis_nova_3p_wireless_send_sidetone; + device_arctis.send_inactive_time = &arctis_nova_3p_wireless_send_inactive_time; device_arctis.send_microphone_volume = &arctis_nova_3p_wireless_send_microphone_volume; *device = &device_arctis; @@ -115,3 +107,35 @@ static int arctis_nova_3p_wireless_send_microphone_volume(hid_device* device_han return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); } + +static int arctis_nova_3p_wireless_send_inactive_time(hid_device* device_handle, uint8_t num) +{ + // the unit of time is the minute + if (num >= 90) { + num = 90; + } else if (num >= 75) { + num = 75; + } else if (num >= 60) { + num = 60; + } else if (num >= 45) { + num = 45; + } else if (num >= 30) { + num = 30; + } else if (num >= 15) { + num = 15; + } else if (num >= 10) { + num = 10; + } else if (num >= 5) { + num = 5; + } else if (num >= 1) { + num = 1; + } else { + // this will NEVER turn off the headset after a period of inactivity + num = 0; + } + + uint8_t data[MSG_SIZE] = { 0xa3, num }; + hid_send_feature_report(device_handle, data, MSG_SIZE); + + return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); +} From b7077b10e463e2d4e5cf61092b9efc84cfdb3ec4 Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Fri, 8 Aug 2025 21:24:06 +0300 Subject: [PATCH 04/14] Refactor: use `map` instead of if-statements --- .../steelseries_arctis_nova_3p_wireless.c | 60 +------------------ 1 file changed, 3 insertions(+), 57 deletions(-) diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.c b/src/devices/steelseries_arctis_nova_3p_wireless.c index 38635a7..bb521f3 100644 --- a/src/devices/steelseries_arctis_nova_3p_wireless.c +++ b/src/devices/steelseries_arctis_nova_3p_wireless.c @@ -1,4 +1,5 @@ #include "../device.h" +#include "../utility.h" #include #include @@ -35,31 +36,7 @@ void arctis_nova_3p_wireless_init(struct device** device) static int arctis_nova_3p_wireless_send_sidetone(hid_device* device_handle, uint8_t num) { - // This headset supports 11 levels of sidetone volume (0x0 to 0xa), - // mapping a full range of 0-128 input values. - if (num < 12) { - num = 0x0; - } else if (num < 24) { - num = 0x1; - } else if (num < 36) { - num = 0x2; - } else if (num < 48) { - num = 0x3; - } else if (num < 60) { - num = 0x4; - } else if (num < 72) { - num = 0x5; - } else if (num < 84) { - num = 0x6; - } else if (num < 96) { - num = 0x7; - } else if (num < 107) { - num = 0x8; - } else if (num < 118) { - num = 0x9; - } else { // num is 118 or greater (up to 128) - num = 0xa; - } + num = map(num, 0, 128, 0, 0xa); uint8_t data[MSG_SIZE] = { 0x39, num }; hid_send_feature_report(device_handle, data, MSG_SIZE); @@ -69,38 +46,7 @@ static int arctis_nova_3p_wireless_send_sidetone(hid_device* device_handle, uint static int arctis_nova_3p_wireless_send_microphone_volume(hid_device* device_handle, uint8_t num) { - // This headset only supports 10 levels of microphone volume, but we allow a full range of 0-128 for it. Map the volume to the correct numbers. - if (num < 9) { - num = 0x00; - } else if (num < 18) { - num = 0x01; - } else if (num < 27) { - num = 0x02; - } else if (num < 36) { - num = 0x03; - } else if (num < 45) { - num = 0x04; - } else if (num < 54) { - num = 0x05; - } else if (num < 63) { - num = 0x06; - } else if (num < 72) { - num = 0x07; - } else if (num < 81) { - num = 0x08; - } else if (num < 90) { - num = 0x09; - } else if (num < 99) { - num = 0x0a; - } else if (num < 108) { - num = 0x0b; - } else if (num < 117) { - num = 0x0c; - } else if (num < 126) { - num = 0x0d; - } else { - num = 0x0e; - } + num = map(num, 0, 128, 0, 0x0e); uint8_t volume[MSG_SIZE] = { 0x37, num }; hid_send_feature_report(device_handle, volume, MSG_SIZE); From 6c68c264e964bf8804725c79e42b60d2e93c9d5e Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Fri, 8 Aug 2025 22:22:56 +0300 Subject: [PATCH 05/14] Feat: Add battery status support for SteelSeries Arctis Nova 3P Wireless --- .../steelseries_arctis_nova_3p_wireless.c | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.c b/src/devices/steelseries_arctis_nova_3p_wireless.c index bb521f3..28671d6 100644 --- a/src/devices/steelseries_arctis_nova_3p_wireless.c +++ b/src/devices/steelseries_arctis_nova_3p_wireless.c @@ -9,6 +9,16 @@ enum { MSG_SIZE = 64 }; enum { ID_ARCTIS_NOVA_3P_WIRELESS = 0x2269 }; +enum { + HEADSET_ONLINE = 0x03, + HEADSET_OFFLINE = 0x02 +}; + +enum { + BATTERY_MAX = 0x64, + BATTERY_MIN = 0x00 +}; + static struct device device_arctis; static const uint16_t PRODUCT_IDS[] = { ID_ARCTIS_NOVA_3P_WIRELESS }; @@ -17,6 +27,7 @@ static const uint8_t SAVE_DATA[MSG_SIZE] = { 0x09 }; // Command to save settings static int arctis_nova_3p_wireless_send_sidetone(hid_device* device_handle, uint8_t num); static int arctis_nova_3p_wireless_send_microphone_volume(hid_device* device_handle, uint8_t num); static int arctis_nova_3p_wireless_send_inactive_time(hid_device* device_handle, uint8_t num); +static BatteryInfo arctis_nova_3p_wireless_request_battery(hid_device* device_handle); void arctis_nova_3p_wireless_init(struct device** device) { @@ -26,10 +37,11 @@ void arctis_nova_3p_wireless_init(struct device** device) strncpy(device_arctis.device_name, "SteelSeries Arctis Nova 3P Wireless", sizeof(device_arctis.device_name)); - device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME); + device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME) | B(CAP_BATTERY_STATUS); device_arctis.send_sidetone = &arctis_nova_3p_wireless_send_sidetone; device_arctis.send_inactive_time = &arctis_nova_3p_wireless_send_inactive_time; device_arctis.send_microphone_volume = &arctis_nova_3p_wireless_send_microphone_volume; + device_arctis.request_battery = &arctis_nova_3p_wireless_request_battery; *device = &device_arctis; } @@ -85,3 +97,45 @@ static int arctis_nova_3p_wireless_send_inactive_time(hid_device* device_handle, return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); } + +static BatteryInfo arctis_nova_3p_wireless_request_battery(hid_device* device_handle) +{ + int r = 0; + + BatteryInfo info = { .status = BATTERY_UNAVAILABLE, .level = -1 }; + + uint8_t data[MSG_SIZE] = { 0xb0 }; + r = hid_send_feature_report(device_handle, data, MSG_SIZE); + + if (r < 0) { + info.status = BATTERY_HIDERROR; + return info; + } + + if (r == 0) { + info.status = BATTERY_TIMEOUT; + return info; + } + + r = hid_read_timeout(device_handle, data, 4, hsc_device_timeout); + + if (r < 0) { + info.status = BATTERY_HIDERROR; + return info; + } + + if (r == 0) { + info.status = BATTERY_TIMEOUT; + return info; + } + + if (data[1] == HEADSET_OFFLINE) { + info.status = BATTERY_UNAVAILABLE; + return info; + } + + info.status = BATTERY_AVAILABLE; + int bat = data[3]; + info.level = map(bat, BATTERY_MIN, BATTERY_MAX, 0, 100); + return info; +} From 7e4929cf7cd4a8171ad9c4bb21a2d73a166083cf Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Sun, 10 Aug 2025 22:01:58 +0300 Subject: [PATCH 06/14] Feat: Add equalizer support for SteelSeries Arctis Nova 3P Wireless --- .../steelseries_arctis_nova_3p_wireless.c | 87 ++++++++++++++++--- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.c b/src/devices/steelseries_arctis_nova_3p_wireless.c index 28671d6..f889c86 100644 --- a/src/devices/steelseries_arctis_nova_3p_wireless.c +++ b/src/devices/steelseries_arctis_nova_3p_wireless.c @@ -5,19 +5,22 @@ #include #include -enum { MSG_SIZE = 64 }; +#define MSG_SIZE 64 -enum { ID_ARCTIS_NOVA_3P_WIRELESS = 0x2269 }; +#define ID_ARCTIS_NOVA_3P_WIRELESS 0x2269 -enum { - HEADSET_ONLINE = 0x03, - HEADSET_OFFLINE = 0x02 -}; +#define HEADSET_ONLINE 0x03 +#define HEADSET_OFFLINE 0x02 -enum { - BATTERY_MAX = 0x64, - BATTERY_MIN = 0x00 -}; +#define BATTERY_MAX 0x64 +#define BATTERY_MIN 0x00 + +#define EQUALIZER_BANDS_COUNT 10 +#define EQUALIZER_GAIN_STEP 0.1 +#define EQUALIZER_GAIN_MIN -12 +#define EQUALIZER_GAIN_MAX 12 + +static EqualizerInfo equalizer = { EQUALIZER_BANDS_COUNT, 0, EQUALIZER_GAIN_STEP, EQUALIZER_GAIN_MIN, EQUALIZER_GAIN_MAX }; static struct device device_arctis; @@ -28,20 +31,23 @@ static int arctis_nova_3p_wireless_send_sidetone(hid_device* device_handle, uint static int arctis_nova_3p_wireless_send_microphone_volume(hid_device* device_handle, uint8_t num); static int arctis_nova_3p_wireless_send_inactive_time(hid_device* device_handle, uint8_t num); static BatteryInfo arctis_nova_3p_wireless_request_battery(hid_device* device_handle); +static int arctis_nova_3p_send_equalizer(hid_device* device_handle, struct equalizer_settings* settings); void arctis_nova_3p_wireless_init(struct device** device) { device_arctis.idVendor = VENDOR_STEELSERIES; device_arctis.idProductsSupported = PRODUCT_IDS; device_arctis.numIdProducts = sizeof(PRODUCT_IDS) / sizeof(PRODUCT_IDS[0]); + device_arctis.equalizer = &equalizer; strncpy(device_arctis.device_name, "SteelSeries Arctis Nova 3P Wireless", sizeof(device_arctis.device_name)); - device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME) | B(CAP_BATTERY_STATUS); + device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME) | B(CAP_BATTERY_STATUS) | B(CAP_EQUALIZER); device_arctis.send_sidetone = &arctis_nova_3p_wireless_send_sidetone; device_arctis.send_inactive_time = &arctis_nova_3p_wireless_send_inactive_time; device_arctis.send_microphone_volume = &arctis_nova_3p_wireless_send_microphone_volume; device_arctis.request_battery = &arctis_nova_3p_wireless_request_battery; + device_arctis.send_equalizer = &arctis_nova_3p_send_equalizer; *device = &device_arctis; } @@ -139,3 +145,62 @@ static BatteryInfo arctis_nova_3p_wireless_request_battery(hid_device* device_ha info.level = map(bat, BATTERY_MIN, BATTERY_MAX, 0, 100); return info; } + +static int arctis_nova_3p_send_equalizer(hid_device* device_handle, struct equalizer_settings* settings) +{ + if (settings->size != EQUALIZER_BANDS_COUNT) { + printf("Device only supports %d bands.\n", EQUALIZER_BANDS_COUNT); + return HSC_OUT_OF_BOUNDS; + } + uint8_t data[MSG_SIZE] = { 0x33 }; + + // default frequencies for each band (2 bytes per band) + const unsigned char band_freq_le[2 * EQUALIZER_BANDS_COUNT] = { + 0x20, + 0x0, + 0x40, + 0x0, + 0x7d, + 0x0, + 0xfa, + 0x0, + 0xf4, + 0x1, + 0xe8, + 0x3, + 0xd0, + 0x7, + 0xa0, + 0xf, + 0x40, + 0x1f, + 0x80, + 0x3e, + }; + + for (size_t i = 0; i < settings->size; i++) { + float gain_value = settings->bands_values[i]; + if (gain_value < EQUALIZER_GAIN_MIN || gain_value > EQUALIZER_GAIN_MAX) { + printf("Device only supports gains ranging from %d to %d.\n", EQUALIZER_GAIN_MIN, EQUALIZER_GAIN_MAX); + return HSC_OUT_OF_BOUNDS; + } + uint8_t raw_gain_value; + if (gain_value < 0) { + gain_value = (-gain_value) / EQUALIZER_GAIN_STEP; + raw_gain_value = (0xff - ((uint8_t)gain_value)) + 1; + } else { + raw_gain_value = (uint8_t)(gain_value / EQUALIZER_GAIN_STEP); + } + + data[1 + 6 * i + 0] = band_freq_le[2 * i]; + data[1 + 6 * i + 1] = band_freq_le[2 * i + 1]; + data[1 + 6 * i + 2] = 0x1; // use Peaking EQ by default + data[1 + 6 * i + 3] = raw_gain_value; + // use a default quality factor of 1.414 (approximated sqrt(2)) multiplied by 1000 to have a fixed 16 bit number number + data[1 + 6 * i + 4] = 0x86; + data[1 + 6 * i + 5] = 0x5; + } + + hid_send_feature_report(device_handle, data, MSG_SIZE); + return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); +} From a7e918f033c72466c2651ff44840358b70136673 Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Mon, 11 Aug 2025 10:52:47 +0300 Subject: [PATCH 07/14] Feat: Add equalizer presets support for SteelSeries Arctis Nova 3P Wireless --- .../steelseries_arctis_nova_3p_wireless.c | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.c b/src/devices/steelseries_arctis_nova_3p_wireless.c index f889c86..35326ba 100644 --- a/src/devices/steelseries_arctis_nova_3p_wireless.c +++ b/src/devices/steelseries_arctis_nova_3p_wireless.c @@ -20,8 +20,23 @@ #define EQUALIZER_GAIN_MIN -12 #define EQUALIZER_GAIN_MAX 12 +#define EQUALIZER_PRESETS_COUNT 4 + static EqualizerInfo equalizer = { EQUALIZER_BANDS_COUNT, 0, EQUALIZER_GAIN_STEP, EQUALIZER_GAIN_MIN, EQUALIZER_GAIN_MAX }; +static float flat[EQUALIZER_BANDS_COUNT] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; +static float bass[EQUALIZER_BANDS_COUNT] = { 3.5f, 5.5f, 4, 1, -1.5f, -1.5f, -1, -1, -1, -1 }; +static float focus[EQUALIZER_BANDS_COUNT] = { -5, -3.5f, -1, -3.5f, -2.5f, 4, 6, -3.5f, 0 }; +static float smiley[EQUALIZER_BANDS_COUNT] = { 3, 3.5f, 1.5f, -1.5f, -4, -4, -2.5f, 1.5f, 3, 4 }; + +static EqualizerPresets equalizer_presets = { + EQUALIZER_PRESETS_COUNT, + { { "flat", flat }, + { "bass", bass }, + { "focus", focus }, + { "smiley", smiley } } +}; + static struct device device_arctis; static const uint16_t PRODUCT_IDS[] = { ID_ARCTIS_NOVA_3P_WIRELESS }; @@ -32,6 +47,7 @@ static int arctis_nova_3p_wireless_send_microphone_volume(hid_device* device_han static int arctis_nova_3p_wireless_send_inactive_time(hid_device* device_handle, uint8_t num); static BatteryInfo arctis_nova_3p_wireless_request_battery(hid_device* device_handle); static int arctis_nova_3p_send_equalizer(hid_device* device_handle, struct equalizer_settings* settings); +static int arctis_nova_3p_send_equalizer_preset(hid_device* device_handle, uint8_t num); void arctis_nova_3p_wireless_init(struct device** device) { @@ -39,15 +55,17 @@ void arctis_nova_3p_wireless_init(struct device** device) device_arctis.idProductsSupported = PRODUCT_IDS; device_arctis.numIdProducts = sizeof(PRODUCT_IDS) / sizeof(PRODUCT_IDS[0]); device_arctis.equalizer = &equalizer; + device_arctis.equalizer_presets = &equalizer_presets; strncpy(device_arctis.device_name, "SteelSeries Arctis Nova 3P Wireless", sizeof(device_arctis.device_name)); - device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME) | B(CAP_BATTERY_STATUS) | B(CAP_EQUALIZER); + device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME) | B(CAP_BATTERY_STATUS) | B(CAP_EQUALIZER) | B(CAP_EQUALIZER_PRESET); device_arctis.send_sidetone = &arctis_nova_3p_wireless_send_sidetone; device_arctis.send_inactive_time = &arctis_nova_3p_wireless_send_inactive_time; device_arctis.send_microphone_volume = &arctis_nova_3p_wireless_send_microphone_volume; device_arctis.request_battery = &arctis_nova_3p_wireless_request_battery; device_arctis.send_equalizer = &arctis_nova_3p_send_equalizer; + device_arctis.send_equalizer_preset = &arctis_nova_3p_send_equalizer_preset; *device = &device_arctis; } @@ -204,3 +222,31 @@ static int arctis_nova_3p_send_equalizer(hid_device* device_handle, struct equal hid_send_feature_report(device_handle, data, MSG_SIZE); return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); } + +static int arctis_nova_3p_send_equalizer_preset(hid_device* device_handle, uint8_t num) +{ + struct equalizer_settings preset; + preset.size = EQUALIZER_BANDS_COUNT; + switch (num) { + case 0: { + preset.bands_values = &flat[0]; + break; + } + case 1: { + preset.bands_values = &bass[0]; + break; + } + case 2: { + preset.bands_values = &focus[0]; + break; + } + case 3: { + preset.bands_values = &smiley[0]; + break; + } + default: + printf("Preset %d out of bounds\n", num); + return HSC_OUT_OF_BOUNDS; + } + return arctis_nova_3p_send_equalizer(device_handle, &preset); +} From 0d1bfce5ea3e7c17899af81e5546bff94730f796 Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Mon, 11 Aug 2025 11:13:43 +0300 Subject: [PATCH 08/14] Style: Run `make format` --- src/device.h | 31 +++++++++++++++++-------------- src/main.c | 24 ++++++++++++------------ 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/device.h b/src/device.h index 7c2c518..74318b7 100644 --- a/src/device.h +++ b/src/device.h @@ -44,10 +44,8 @@ enum capabilities { NUM_CAPABILITIES }; -enum capabilitytype { - CAPABILITYTYPE_ACTION, - CAPABILITYTYPE_INFO -}; +enum capabilitytype { CAPABILITYTYPE_ACTION, + CAPABILITYTYPE_INFO }; /// Long name of every capability extern const char* const capabilities_str[NUM_CAPABILITIES]; @@ -59,7 +57,8 @@ extern const char* const capabilities_str_enum[NUM_CAPABILITIES]; struct capability_detail { // Usage page, only used when usageid is not 0; HID Protocol specific uint16_t usagepage; - // Used instead of interface when not 0, and only used on Windows currently; HID Protocol specific + // Used instead of interface when not 0, and only used on Windows currently; + // HID Protocol specific uint16_t usageid; /// Interface ID - zero means first enumerated interface! int interface; @@ -139,7 +138,8 @@ typedef struct { int value; /// Status depending on the feature (not used by all) int status2; - /// For error messages, "Charging", "Unavailable", etc. Should be free()d after use + /// For error messages, "Charging", "Unavailable", etc. Should be free()d + /// after use char* message; } FeatureResult; @@ -358,7 +358,8 @@ struct device { * specific to this hardware * -1 HIDAPI error */ - int (*send_equalizer)(hid_device* hid_device, struct equalizer_settings* settings); + int (*send_equalizer)(hid_device* hid_device, + struct equalizer_settings* settings); /** @brief Function pointer for setting headset parametric equalizer * @@ -372,11 +373,12 @@ struct device { * @returns > 0 on success * HSC_OUT_OF_BOUNDS on equalizer settings size out of range * specific to this hardware - * HSC_INVALID_ARG on equalizer filter type invalid/unsupported - * specific to this hardware - * -1 HIDAPI error + * HSC_INVALID_ARG on equalizer filter type + * invalid/unsupported specific to this hardware -1 HIDAPI + * error */ - int (*send_parametric_equalizer)(hid_device* hid_device, struct parametric_equalizer_settings* settings); + int (*send_parametric_equalizer)( + hid_device* hid_device, struct parametric_equalizer_settings* settings); /** @brief Function pointer for setting headset microphone mute LED brightness * @@ -391,7 +393,8 @@ struct device { * specific to this hardware * -1 HIDAPI error */ - int (*send_microphone_mute_led_brightness)(hid_device* hid_device, uint8_t num); + int (*send_microphone_mute_led_brightness)(hid_device* hid_device, + uint8_t num); /** @brief Function pointer for setting headset microphone volume * @@ -458,8 +461,8 @@ struct device { /** * @brief Node structure for a linked list of devices. * - * This structure represents a node in a linked list where each node contains a pointer to a device - * and a pointer to the next node in the list. + * This structure represents a node in a linked list where each node contains a + * pointer to a device and a pointer to the next node in the list. */ typedef struct DeviceListNode { struct device* element; diff --git a/src/main.c b/src/main.c index 9240596..1e3f387 100644 --- a/src/main.c +++ b/src/main.c @@ -271,7 +271,7 @@ static FeatureResult handle_feature(struct device* device_found, hid_device** de switch (cap) { case CAP_SIDETONE: - ret = device_found->send_sidetone(*device_handle, (uint8_t) * (int*)param); + ret = device_found->send_sidetone(*device_handle, (uint8_t)*(int*)param); break; case CAP_BATTERY_STATUS: { @@ -307,15 +307,15 @@ static FeatureResult handle_feature(struct device* device_found, hid_device** de } case CAP_NOTIFICATION_SOUND: - ret = device_found->notification_sound(*device_handle, (uint8_t) * (int*)param); + ret = device_found->notification_sound(*device_handle, (uint8_t)*(int*)param); break; case CAP_LIGHTS: - ret = device_found->switch_lights(*device_handle, (uint8_t) * (int*)param); + ret = device_found->switch_lights(*device_handle, (uint8_t)*(int*)param); break; case CAP_INACTIVE_TIME: - ret = device_found->send_inactive_time(*device_handle, (uint8_t) * (int*)param); + ret = device_found->send_inactive_time(*device_handle, (uint8_t)*(int*)param); break; case CAP_CHATMIX_STATUS: @@ -334,15 +334,15 @@ static FeatureResult handle_feature(struct device* device_found, hid_device** de return result; case CAP_VOICE_PROMPTS: - ret = device_found->switch_voice_prompts(*device_handle, (uint8_t) * (int*)param); + ret = device_found->switch_voice_prompts(*device_handle, (uint8_t)*(int*)param); break; case CAP_ROTATE_TO_MUTE: - ret = device_found->switch_rotate_to_mute(*device_handle, (uint8_t) * (int*)param); + ret = device_found->switch_rotate_to_mute(*device_handle, (uint8_t)*(int*)param); break; case CAP_EQUALIZER_PRESET: - ret = device_found->send_equalizer_preset(*device_handle, (uint8_t) * (int*)param); + ret = device_found->send_equalizer_preset(*device_handle, (uint8_t)*(int*)param); break; case CAP_EQUALIZER: @@ -354,23 +354,23 @@ static FeatureResult handle_feature(struct device* device_found, hid_device** de break; case CAP_MICROPHONE_MUTE_LED_BRIGHTNESS: - ret = device_found->send_microphone_mute_led_brightness(*device_handle, (uint8_t) * (int*)param); + ret = device_found->send_microphone_mute_led_brightness(*device_handle, (uint8_t)*(int*)param); break; case CAP_MICROPHONE_VOLUME: - ret = device_found->send_microphone_volume(*device_handle, (uint8_t) * (int*)param); + ret = device_found->send_microphone_volume(*device_handle, (uint8_t)*(int*)param); break; case CAP_VOLUME_LIMITER: - ret = device_found->send_volume_limiter(*device_handle, (uint8_t) * (int*)param); + ret = device_found->send_volume_limiter(*device_handle, (uint8_t)*(int*)param); break; case CAP_BT_WHEN_POWERED_ON: - ret = device_found->send_bluetooth_when_powered_on(*device_handle, (uint8_t) * (int*)param); + ret = device_found->send_bluetooth_when_powered_on(*device_handle, (uint8_t)*(int*)param); break; case CAP_BT_CALL_VOLUME: - ret = device_found->send_bluetooth_call_volume(*device_handle, (uint8_t) * (int*)param); + ret = device_found->send_bluetooth_call_volume(*device_handle, (uint8_t)*(int*)param); break; case NUM_CAPABILITIES: From 1f081d9674b2fc23a984a8b642a99ee70c6ab7c2 Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Mon, 11 Aug 2025 11:16:00 +0300 Subject: [PATCH 09/14] Feat: Add parametric equalizer support for SteelSeries Arctis Nova 3P Wireless --- src/device.c | 3 +- src/device.h | 1 + .../steelseries_arctis_nova_3p_wireless.c | 139 ++++++++++++++++-- 3 files changed, 130 insertions(+), 13 deletions(-) diff --git a/src/device.c b/src/device.c index a566a24..131d05e 100644 --- a/src/device.c +++ b/src/device.c @@ -67,7 +67,8 @@ const char* const equalizer_filter_type_str[NUM_EQ_FILTER_TYPES] [EQ_FILTER_LOWPASS] = "lowpass", [EQ_FILTER_PEAKING] = "peaking", [EQ_FILTER_HIGHPASS] = "highpass", - [EQ_FILTER_HIGHSHELF] = "highshelf" + [EQ_FILTER_HIGHSHELF] = "highshelf", + [EQ_FILTER_NOTCH] = "notch" }; diff --git a/src/device.h b/src/device.h index 74318b7..dc49139 100644 --- a/src/device.h +++ b/src/device.h @@ -166,6 +166,7 @@ typedef enum { EQ_FILTER_PEAKING, EQ_FILTER_HIGHPASS, EQ_FILTER_HIGHSHELF, + EQ_FILTER_NOTCH, NUM_EQ_FILTER_TYPES } EqualizerFilterType; diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.c b/src/devices/steelseries_arctis_nova_3p_wireless.c index 35326ba..11573c0 100644 --- a/src/devices/steelseries_arctis_nova_3p_wireless.c +++ b/src/devices/steelseries_arctis_nova_3p_wireless.c @@ -17,11 +17,20 @@ #define EQUALIZER_BANDS_COUNT 10 #define EQUALIZER_GAIN_STEP 0.1 +#define EQUALIZER_GAIN_BASE 0 #define EQUALIZER_GAIN_MIN -12 #define EQUALIZER_GAIN_MAX 12 #define EQUALIZER_PRESETS_COUNT 4 +#define EQUALIZER_Q_FACTOR_MIN 0.2 +#define EQUALIZER_Q_FACTOR_MAX 10.0 +#define EQUALIZER_Q_FACTOR_DEFAULT 1.414 +#define EQUALIZER_FREQ_MIN 20 +#define EQUALIZER_FREQ_MAX 20000 +#define EQUALIZER_FREQ_DISABLED 20001 +#define EQUALIZER_FILTERS (B(EQ_FILTER_LOWSHELF) | B(EQ_FILTER_LOWPASS) | B(EQ_FILTER_PEAKING) | B(EQ_FILTER_HIGHPASS) | B(EQ_FILTER_HIGHSHELF) | B(EQ_FILTER_NOTCH)) + static EqualizerInfo equalizer = { EQUALIZER_BANDS_COUNT, 0, EQUALIZER_GAIN_STEP, EQUALIZER_GAIN_MIN, EQUALIZER_GAIN_MAX }; static float flat[EQUALIZER_BANDS_COUNT] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; @@ -37,6 +46,28 @@ static EqualizerPresets equalizer_presets = { { "smiley", smiley } } }; +static ParametricEqualizerInfo parametric_equalizer = { + EQUALIZER_BANDS_COUNT, + EQUALIZER_GAIN_BASE, + EQUALIZER_GAIN_STEP, + EQUALIZER_GAIN_MIN, + EQUALIZER_GAIN_MAX, + EQUALIZER_Q_FACTOR_MIN, + EQUALIZER_Q_FACTOR_MAX, + EQUALIZER_FREQ_MIN, + EQUALIZER_FREQ_MAX, + EQUALIZER_FILTERS +}; + +static uint8_t EQUALIZER_FILTER_MAP[NUM_EQ_FILTER_TYPES] = { + [EQ_FILTER_PEAKING] = 1, + [EQ_FILTER_LOWPASS] = 2, + [EQ_FILTER_HIGHPASS] = 3, + [EQ_FILTER_LOWSHELF] = 4, + [EQ_FILTER_HIGHSHELF] = 5, + [EQ_FILTER_NOTCH] = 6, +}; + static struct device device_arctis; static const uint16_t PRODUCT_IDS[] = { ID_ARCTIS_NOVA_3P_WIRELESS }; @@ -48,24 +79,28 @@ static int arctis_nova_3p_wireless_send_inactive_time(hid_device* device_handle, static BatteryInfo arctis_nova_3p_wireless_request_battery(hid_device* device_handle); static int arctis_nova_3p_send_equalizer(hid_device* device_handle, struct equalizer_settings* settings); static int arctis_nova_3p_send_equalizer_preset(hid_device* device_handle, uint8_t num); +static int arctis_nova_3p_send_parametric_equalizer(hid_device* device_handle, struct parametric_equalizer_settings* settings); +static int arctis_nova_3p_write_device_band(struct parametric_equalizer_band* filter, uint8_t* data); void arctis_nova_3p_wireless_init(struct device** device) { - device_arctis.idVendor = VENDOR_STEELSERIES; - device_arctis.idProductsSupported = PRODUCT_IDS; - device_arctis.numIdProducts = sizeof(PRODUCT_IDS) / sizeof(PRODUCT_IDS[0]); - device_arctis.equalizer = &equalizer; - device_arctis.equalizer_presets = &equalizer_presets; + device_arctis.idVendor = VENDOR_STEELSERIES; + device_arctis.idProductsSupported = PRODUCT_IDS; + device_arctis.numIdProducts = sizeof(PRODUCT_IDS) / sizeof(PRODUCT_IDS[0]); + device_arctis.equalizer = &equalizer; + device_arctis.equalizer_presets = &equalizer_presets; + device_arctis.parametric_equalizer = ¶metric_equalizer; strncpy(device_arctis.device_name, "SteelSeries Arctis Nova 3P Wireless", sizeof(device_arctis.device_name)); - device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME) | B(CAP_BATTERY_STATUS) | B(CAP_EQUALIZER) | B(CAP_EQUALIZER_PRESET); - device_arctis.send_sidetone = &arctis_nova_3p_wireless_send_sidetone; - device_arctis.send_inactive_time = &arctis_nova_3p_wireless_send_inactive_time; - device_arctis.send_microphone_volume = &arctis_nova_3p_wireless_send_microphone_volume; - device_arctis.request_battery = &arctis_nova_3p_wireless_request_battery; - device_arctis.send_equalizer = &arctis_nova_3p_send_equalizer; - device_arctis.send_equalizer_preset = &arctis_nova_3p_send_equalizer_preset; + device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME) | B(CAP_BATTERY_STATUS) | B(CAP_EQUALIZER) | B(CAP_EQUALIZER_PRESET) | B(CAP_PARAMETRIC_EQUALIZER); + device_arctis.send_sidetone = &arctis_nova_3p_wireless_send_sidetone; + device_arctis.send_inactive_time = &arctis_nova_3p_wireless_send_inactive_time; + device_arctis.send_microphone_volume = &arctis_nova_3p_wireless_send_microphone_volume; + device_arctis.request_battery = &arctis_nova_3p_wireless_request_battery; + device_arctis.send_equalizer = &arctis_nova_3p_send_equalizer; + device_arctis.send_equalizer_preset = &arctis_nova_3p_send_equalizer_preset; + device_arctis.send_parametric_equalizer = &arctis_nova_3p_send_parametric_equalizer; *device = &device_arctis; } @@ -250,3 +285,83 @@ static int arctis_nova_3p_send_equalizer_preset(hid_device* device_handle, uint8 } return arctis_nova_3p_send_equalizer(device_handle, &preset); } + +static int arctis_nova_3p_send_parametric_equalizer(hid_device* device_handle, struct parametric_equalizer_settings* settings) +{ + struct parametric_equalizer_band disabled_band = { + .frequency = EQUALIZER_FREQ_DISABLED, + .gain = 0, + .q_factor = EQUALIZER_Q_FACTOR_DEFAULT, + .type = EQ_FILTER_PEAKING, + }; + + if (settings->size > EQUALIZER_BANDS_COUNT) { + printf("Device only supports up to %d equalizer bands.\n", EQUALIZER_BANDS_COUNT); + return HSC_OUT_OF_BOUNDS; + } + + uint8_t data[MSG_SIZE] = { 0x33 }; + int error = 0; + + for (size_t i = 0; i < EQUALIZER_BANDS_COUNT; i++) { + if (i < settings->size) { + error = arctis_nova_3p_write_device_band(&settings->bands[i], &data[1 + i * 6]); + } else { + error = arctis_nova_3p_write_device_band(&disabled_band, &data[1 + i * 6]); + } + if (error != 0) { + return error; + } + } + + hid_send_feature_report(device_handle, data, MSG_SIZE); + return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); +} + +static int arctis_nova_3p_write_device_band(struct parametric_equalizer_band* filter, uint8_t* data) +{ + // fprintf(stderr, "freq: %g; gain: %g; q: %g; type: %d\n", filter->frequency, filter->gain, filter->q_factor, filter->type); + if (filter->frequency != EQUALIZER_FREQ_DISABLED) { + if (filter->frequency < EQUALIZER_FREQ_MIN || filter->frequency > EQUALIZER_FREQ_MAX) { + printf("Device only supports filter frequencies ranging from %d to %d.\n", EQUALIZER_FREQ_MIN, EQUALIZER_FREQ_MAX); + return HSC_OUT_OF_BOUNDS; + } + } + if (!has_capability(EQUALIZER_FILTERS, (int)filter->type)) { + printf("Unsupported filter type.\n"); + return HSC_ERROR; + } + if (filter->q_factor < EQUALIZER_Q_FACTOR_MIN || filter->q_factor > EQUALIZER_Q_FACTOR_MAX) { + printf("Device only supports filter q-factor ranging from %g to %g.\n", EQUALIZER_Q_FACTOR_MIN, EQUALIZER_Q_FACTOR_MAX); + return HSC_OUT_OF_BOUNDS; + } + if (filter->gain < EQUALIZER_GAIN_MIN || filter->gain > EQUALIZER_GAIN_MAX) { + printf("Device only supports filter gains ranging from %d to %d.\n", EQUALIZER_GAIN_MIN, EQUALIZER_GAIN_MAX); + return HSC_OUT_OF_BOUNDS; + } + + // write filter frequency + data[0] = (uint16_t)filter->frequency & 0xFF; // low byte + data[1] = ((uint16_t)filter->frequency >> 8) & 0xFF; // high byte + + // write filter type + data[2] = EQUALIZER_FILTER_MAP[filter->type]; + + // write filter gain + float gain_value = filter->gain; + uint8_t raw_gain_value; + if (gain_value < 0) { + gain_value = (-gain_value) / EQUALIZER_GAIN_STEP; + raw_gain_value = (0xff - ((uint8_t)gain_value)) + 1; + } else { + raw_gain_value = (uint8_t)(gain_value / EQUALIZER_GAIN_STEP); + } + data[3] = raw_gain_value; + + // write filter q-factor + uint16_t q = filter->q_factor * 1000; + data[4] = q & 0xFF; // low byte + data[5] = (q >> 8) & 0xFF; // high byte + + return 0; +} From 11d9f2442e80f47957ab62f4ccf2f265d1cc5418 Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Mon, 11 Aug 2025 12:09:38 +0300 Subject: [PATCH 10/14] Docs: Update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 09185fe..8fb5056 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ talking. This differs from a simple loopback via PulseAudio as you won't have an | ROCCAT Elo 7.1 Air | | | | x | x | | | | | | | | | | | | | ROCCAT Elo 7.1 USB | | | | x | | | | | | | | | | | | | | SteelSeries Arctis Nova 3 | x | | | | | | | | x | x | | x | x | | | | +| SteelSeries Arctis Nova 3P Wireless | x | x | | | x | | | | x | x | x | | x | | | | | SteelSeries Arctis Nova (5/5X) | x | x | | | x | x | | | x | x | x | x | x | x | | | | SteelSeries Arctis Nova 7 | x | x | | | x | x | | | x | x | | x | x | x | x | x | | SteelSeries Arctis 7+ | x | x | | | x | x | | | x | x | | | | | | | From f2e9d8cd44b598ced52bf566f52acd67ac864618 Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Mon, 11 Aug 2025 12:48:05 +0300 Subject: [PATCH 11/14] Fix: Write capability details --- src/devices/steelseries_arctis_nova_3p_wireless.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.c b/src/devices/steelseries_arctis_nova_3p_wireless.c index 11573c0..f66b886 100644 --- a/src/devices/steelseries_arctis_nova_3p_wireless.c +++ b/src/devices/steelseries_arctis_nova_3p_wireless.c @@ -93,7 +93,16 @@ void arctis_nova_3p_wireless_init(struct device** device) strncpy(device_arctis.device_name, "SteelSeries Arctis Nova 3P Wireless", sizeof(device_arctis.device_name)); - device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME) | B(CAP_BATTERY_STATUS) | B(CAP_EQUALIZER) | B(CAP_EQUALIZER_PRESET) | B(CAP_PARAMETRIC_EQUALIZER); + device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME) | B(CAP_BATTERY_STATUS) | B(CAP_EQUALIZER) | B(CAP_EQUALIZER_PRESET) | B(CAP_PARAMETRIC_EQUALIZER); + + device_arctis.capability_details[CAP_SIDETONE] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 0 }; + device_arctis.capability_details[CAP_INACTIVE_TIME] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 0 }; + device_arctis.capability_details[CAP_MICROPHONE_VOLUME] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 0 }; + device_arctis.capability_details[CAP_BATTERY_STATUS] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 0 }; + device_arctis.capability_details[CAP_EQUALIZER] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 0 }; + device_arctis.capability_details[CAP_EQUALIZER_PRESET] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 0 }; + device_arctis.capability_details[CAP_PARAMETRIC_EQUALIZER] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 0 }; + device_arctis.send_sidetone = &arctis_nova_3p_wireless_send_sidetone; device_arctis.send_inactive_time = &arctis_nova_3p_wireless_send_inactive_time; device_arctis.send_microphone_volume = &arctis_nova_3p_wireless_send_microphone_volume; From 16a6fd25b861e5f221a4e44dbea382ffe4effee9 Mon Sep 17 00:00:00 2001 From: Marc-Alexander Grec Date: Mon, 11 Aug 2025 13:37:16 +0300 Subject: [PATCH 12/14] Refactor: create function for saving state --- .../steelseries_arctis_nova_3p_wireless.c | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.c b/src/devices/steelseries_arctis_nova_3p_wireless.c index f66b886..54ffcb6 100644 --- a/src/devices/steelseries_arctis_nova_3p_wireless.c +++ b/src/devices/steelseries_arctis_nova_3p_wireless.c @@ -71,7 +71,7 @@ static uint8_t EQUALIZER_FILTER_MAP[NUM_EQ_FILTER_TYPES] = { static struct device device_arctis; static const uint16_t PRODUCT_IDS[] = { ID_ARCTIS_NOVA_3P_WIRELESS }; -static const uint8_t SAVE_DATA[MSG_SIZE] = { 0x09 }; // Command to save settings to headset +static const uint8_t SAVE_DATA[MSG_SIZE] = { 0x09 }; static int arctis_nova_3p_wireless_send_sidetone(hid_device* device_handle, uint8_t num); static int arctis_nova_3p_wireless_send_microphone_volume(hid_device* device_handle, uint8_t num); @@ -81,6 +81,7 @@ static int arctis_nova_3p_send_equalizer(hid_device* device_handle, struct equal static int arctis_nova_3p_send_equalizer_preset(hid_device* device_handle, uint8_t num); static int arctis_nova_3p_send_parametric_equalizer(hid_device* device_handle, struct parametric_equalizer_settings* settings); static int arctis_nova_3p_write_device_band(struct parametric_equalizer_band* filter, uint8_t* data); +static int arctis_nova_3p_wireless_save_state(hid_device* device_handle); void arctis_nova_3p_wireless_init(struct device** device) { @@ -121,7 +122,7 @@ static int arctis_nova_3p_wireless_send_sidetone(hid_device* device_handle, uint uint8_t data[MSG_SIZE] = { 0x39, num }; hid_send_feature_report(device_handle, data, MSG_SIZE); - return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); + return arctis_nova_3p_wireless_save_state(device_handle); } static int arctis_nova_3p_wireless_send_microphone_volume(hid_device* device_handle, uint8_t num) @@ -131,7 +132,7 @@ static int arctis_nova_3p_wireless_send_microphone_volume(hid_device* device_han uint8_t volume[MSG_SIZE] = { 0x37, num }; hid_send_feature_report(device_handle, volume, MSG_SIZE); - return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); + return arctis_nova_3p_wireless_save_state(device_handle); } static int arctis_nova_3p_wireless_send_inactive_time(hid_device* device_handle, uint8_t num) @@ -163,7 +164,7 @@ static int arctis_nova_3p_wireless_send_inactive_time(hid_device* device_handle, uint8_t data[MSG_SIZE] = { 0xa3, num }; hid_send_feature_report(device_handle, data, MSG_SIZE); - return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); + return arctis_nova_3p_wireless_save_state(device_handle); } static BatteryInfo arctis_nova_3p_wireless_request_battery(hid_device* device_handle) @@ -264,7 +265,7 @@ static int arctis_nova_3p_send_equalizer(hid_device* device_handle, struct equal } hid_send_feature_report(device_handle, data, MSG_SIZE); - return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); + return arctis_nova_3p_wireless_save_state(device_handle); } static int arctis_nova_3p_send_equalizer_preset(hid_device* device_handle, uint8_t num) @@ -324,7 +325,7 @@ static int arctis_nova_3p_send_parametric_equalizer(hid_device* device_handle, s } hid_send_feature_report(device_handle, data, MSG_SIZE); - return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); + return arctis_nova_3p_wireless_save_state(device_handle); } static int arctis_nova_3p_write_device_band(struct parametric_equalizer_band* filter, uint8_t* data) @@ -374,3 +375,8 @@ static int arctis_nova_3p_write_device_band(struct parametric_equalizer_band* fi return 0; } + +static int arctis_nova_3p_wireless_save_state(hid_device* device_handle) +{ + return hid_send_feature_report(device_handle, SAVE_DATA, MSG_SIZE); +} From 0cbfaddb16ec794c3bf03de21d4839b756028a9c Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Wed, 29 Oct 2025 16:18:30 +0100 Subject: [PATCH 13/14] Support for Platform specification --- README.md | 62 ++++++++++--------- src/device.h | 9 +++ src/device_registry.c | 18 +++++- .../steelseries_arctis_nova_3p_wireless.c | 5 ++ src/main.c | 24 ++++++- 5 files changed, 85 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 8fb5056..d33ddda 100644 --- a/README.md +++ b/README.md @@ -9,35 +9,39 @@ talking. This differs from a simple loopback via PulseAudio as you won't have an ## Supported Headsets -| Device | 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 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Audeze Maxwell | x | x | | | x | x | x | | x | | | | | x | | | -| Corsair Headset Device | x | x | x | x | | | | | | | | | | | | | -| HyperX Cloud Alpha Wireless | x | x | | | x | | x | | | | | | | | | | -| HyperX Cloud Flight Wireless | | x | | | | | | | | | | | | | | | -| HyperX Cloud 3 | x | | | | | | | | | | | | | | | | -| Logitech G430 | x | | | | | | | | | | | | | | | | -| Logitech G432/G433 | x | | | | | | | | | | | | | | | | -| Logitech G533 | x | x | | | x | | | | | | | | | | | | -| Logitech G535 | x | x | | | x | | | | | | | | | | | | -| Logitech G930 | x | x | | | | | | | | | | | | | | | -| Logitech G633/G635/G733/G933/G935 | x | x | | x | | | | | | | | | | | | | -| Logitech G PRO Series | x | x | | | x | | | | | | | | | | | | -| Logitech G PRO X 2 | x | | | | x | | | | | | | | | | | | -| Logitech Zone Wired/Zone 750 | x | | | | | | x | x | | | | | | | | | -| SteelSeries Arctis (1/7X/7P) Wireless | x | x | | | x | | | | | | | | | | | | -| SteelSeries Arctis (7/Pro) | x | x | | x | x | x | | | | | | | | | | | -| SteelSeries Arctis 9 | x | x | | | x | x | | | | | | | | | | | -| SteelSeries Arctis Pro Wireless | x | x | | | x | | | | | | | | | | | | -| ROCCAT Elo 7.1 Air | | | | x | x | | | | | | | | | | | | -| ROCCAT Elo 7.1 USB | | | | x | | | | | | | | | | | | | -| SteelSeries Arctis Nova 3 | x | | | | | | | | x | x | | x | x | | | | -| SteelSeries Arctis Nova 3P Wireless | x | x | | | x | | | | x | x | x | | x | | | | -| SteelSeries Arctis Nova (5/5X) | x | x | | | x | x | | | x | x | x | x | x | x | | | -| SteelSeries Arctis Nova 7 | x | x | | | x | x | | | x | x | | x | x | x | x | x | -| SteelSeries Arctis 7+ | x | x | | | x | x | | | x | x | | | | | | | -| SteelSeries Arctis Nova Pro Wireless | x | x | | x | x | | | | x | x | | | | | | | -| HeadsetControl Test device | 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 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Audeze Maxwell | All | x | x | | | x | x | x | | x | | | | | x | | | +| Corsair Headset Device | All | x | x | x | x | | | | | | | | | | | | | +| HyperX Cloud Alpha Wireless | All | x | x | | | x | | x | | | | | | | | | | +| HyperX Cloud Flight Wireless | All | | x | | | | | | | | | | | | | | | +| HyperX Cloud 3 | All | x | | | | | | | | | | | | | | | | +| Logitech G430 | All | x | | | | | | | | | | | | | | | | +| Logitech G432/G433 | All | x | | | | | | | | | | | | | | | | +| Logitech G533 | All | x | x | | | x | | | | | | | | | | | | +| Logitech G535 | All | x | x | | | x | | | | | | | | | | | | +| Logitech G930 | All | x | x | | | | | | | | | | | | | | | +| Logitech G633/G635/G733/G933/G935 | All | x | x | | x | | | | | | | | | | | | | +| Logitech G PRO Series | All | x | x | | | x | | | | | | | | | | | | +| Logitech G PRO X 2 | All | x | | | | x | | | | | | | | | | | | +| Logitech Zone Wired/Zone 750 | 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 | | | | | | | | | | | | +| ROCCAT Elo 7.1 Air | All | | | | x | x | | | | | | | | | | | | +| ROCCAT Elo 7.1 USB | All | | | | x | | | | | | | | | | | | | +| SteelSeries Arctis Nova 3 | All | x | | | | | | | | x | x | | x | x | | | | +| SteelSeries Arctis Nova 3P Wireless | L/M | x | x | | | 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 7+ | All | x | x | | | x | x | | | x | x | | | | | | | +| SteelSeries Arctis Nova Pro Wireless | All | x | x | | x | x | | | | x | x | | | | | | | +| HeadsetControl Test device | All | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | + +**Platform Legend:** +- **All**: Linux, macOS, and Windows +- **L/M**: Linux and macOS only (Windows not supported due to HID implementation differences) For non-supported headsets on Linux: There is a chance that you can set the sidetone via AlsaMixer diff --git a/src/device.h b/src/device.h index dc49139..40f297b 100644 --- a/src/device.h +++ b/src/device.h @@ -16,6 +16,12 @@ /// Convert given number to bitmask #define B(X) (1 << X) +/// Platform support flags +#define PLATFORM_LINUX (1 << 0) +#define PLATFORM_MACOS (1 << 1) +#define PLATFORM_WINDOWS (1 << 2) +#define PLATFORM_ALL (PLATFORM_LINUX | PLATFORM_MACOS | PLATFORM_WINDOWS) + /// global read timeout in millisecounds extern int hsc_device_timeout; @@ -208,6 +214,9 @@ struct device { /// Name of device, used as information for the user char device_name[64]; + /// Bitmask of supported platforms (PLATFORM_LINUX | PLATFORM_MACOS | PLATFORM_WINDOWS) + uint8_t supported_platforms; + // Equalizer Infos EqualizerInfo* equalizer; EqualizerPresets* equalizer_presets; diff --git a/src/device_registry.c b/src/device_registry.c index 9aa8f0e..22c75bb 100644 --- a/src/device_registry.c +++ b/src/device_registry.c @@ -80,6 +80,21 @@ void init_devices() void add_device(void (*init_func)(struct device**)) { + struct device* new_device = NULL; + + // Initialize the new device + init_func(&new_device); + + // Skip if device is NULL (platform not supported) + if (new_device == NULL) { + return; + } + + // Set default platform support to all platforms if not specified + if (new_device->supported_platforms == 0) { + new_device->supported_platforms = PLATFORM_ALL; + } + // Reallocate memory to accommodate the new device struct device** temp = realloc(devicelist, (num_devices + 1) * sizeof(struct device*)); if (temp == NULL) { @@ -88,8 +103,7 @@ void add_device(void (*init_func)(struct device**)) } devicelist = temp; - // Initialize the new device - init_func(&devicelist[num_devices]); + devicelist[num_devices] = new_device; num_devices++; } diff --git a/src/devices/steelseries_arctis_nova_3p_wireless.c b/src/devices/steelseries_arctis_nova_3p_wireless.c index 54ffcb6..72011f4 100644 --- a/src/devices/steelseries_arctis_nova_3p_wireless.c +++ b/src/devices/steelseries_arctis_nova_3p_wireless.c @@ -94,6 +94,11 @@ void arctis_nova_3p_wireless_init(struct device** device) strncpy(device_arctis.device_name, "SteelSeries Arctis Nova 3P Wireless", sizeof(device_arctis.device_name)); + // Windows support not yet working - HID packet length issues + // Functions execute but have no effect on the device + // See: https://github.com/Sapd/HeadsetControl/pull/417 + device_arctis.supported_platforms = PLATFORM_LINUX | PLATFORM_MACOS; + device_arctis.capabilities = B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_MICROPHONE_VOLUME) | B(CAP_BATTERY_STATUS) | B(CAP_EQUALIZER) | B(CAP_EQUALIZER_PRESET) | B(CAP_PARAMETRIC_EQUALIZER); device_arctis.capability_details[CAP_SIDETONE] = (struct capability_detail) { .usagepage = 0xffc0, .usageid = 0x1, .interface = 0 }; diff --git a/src/main.c b/src/main.c index 1e3f387..6b7f2d2 100644 --- a/src/main.c +++ b/src/main.c @@ -155,13 +155,13 @@ static void print_readmetable() int i = 0; struct device* device_found; - printf("| Device |"); + printf("| Device | Platform |"); for (int j = 0; j < NUM_CAPABILITIES; j++) { printf(" %s |", capabilities_str[j]); } printf("\n"); - printf("| --- |"); + printf("| --- | --- |"); for (int j = 0; j < NUM_CAPABILITIES; j++) { printf(" --- |"); } @@ -170,6 +170,26 @@ static void print_readmetable() while (iterate_devices(i++, &device_found) == 0) { printf("| %s |", device_found->device_name); + // Display platform support based on supported_platforms flags + uint8_t platforms = device_found->supported_platforms; + if (platforms == PLATFORM_ALL) { + printf(" All |"); + } else if (platforms == (PLATFORM_LINUX | PLATFORM_MACOS)) { + printf(" L/M |"); + } else if (platforms == (PLATFORM_LINUX | PLATFORM_WINDOWS)) { + printf(" L/W |"); + } else if (platforms == (PLATFORM_MACOS | PLATFORM_WINDOWS)) { + printf(" M/W |"); + } else if (platforms == PLATFORM_LINUX) { + printf(" L |"); + } else if (platforms == PLATFORM_MACOS) { + printf(" M |"); + } else if (platforms == PLATFORM_WINDOWS) { + printf(" W |"); + } else { + printf(" ? |"); // Unknown/invalid combination + } + for (int j = 0; j < NUM_CAPABILITIES; j++) { if (device_has_capability(device_found, j)) { printf(" x |"); From 42a5720ebfb6dad1b87dc915268578cf6e6286b0 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Wed, 29 Oct 2025 16:22:24 +0100 Subject: [PATCH 14/14] Format --- src/main.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.c b/src/main.c index 6b7f2d2..a50eadd 100644 --- a/src/main.c +++ b/src/main.c @@ -187,7 +187,7 @@ static void print_readmetable() } else if (platforms == PLATFORM_WINDOWS) { printf(" W |"); } else { - printf(" ? |"); // Unknown/invalid combination + printf(" ? |"); // Unknown/invalid combination } for (int j = 0; j < NUM_CAPABILITIES; j++) {