Some features are read-only: either they require Windows APO, or they really don't have a software command available. Add the plumbing to detect and report this. Also, add documentation for the featureset.

This commit is contained in:
Fabio Scaccabarozzi
2025-10-19 18:21:31 +01:00
parent f447baa244
commit 80976df212
4 changed files with 325 additions and 27 deletions

89
FEATURE_CAPABILITIES.md Normal file
View File

@@ -0,0 +1,89 @@
# HyperX Headset Feature Capabilities
This document summarizes which features can be controlled vs only monitored for each headset model.
## Cloud II Wireless (Non-DTS)
### Writable Features (Can be SET via HID commands)
-**Auto Power Off** - Can set automatic shutdown timer (0-30 minutes)
-**Sidetone** - Can enable/disable sidetone (on/off only, no volume control)
### Read-Only Features (Can only monitor, not control)
-**Microphone Mute** - Hardware button only, cannot be controlled via HID
-**Surround Sound (7.1)** - Controlled via Windows DTS APO or physical button, not HID
-**Battery Level** - Read-only status
-**Charging Status** - Read-only status
-**Connection Status** - Read-only status
-**Firmware Version** - Read-only information
## Cloud II Wireless DTS
### Writable Features
-**Auto Power Off**
-**Sidetone** - With volume control (0-100)
-**Surround Sound** - Via Windows DTS APO system calls (not direct HID)
### Read-Only Features
-**Microphone Mute** - Hardware button only
-**Battery Level**
-**Charging Status**
-**Connection Status**
## Cloud III Wireless
### Writable Features
-**Auto Power Off**
-**Sidetone** - With volume control (0-100)
-**Microphone Mute** - Can be controlled programmatically
-**Voice Prompt** - Can enable/disable voice prompts
-**Playback Mute (Silent Mode)** - Can mute headphone output
### Read-Only Features
-**Surround Sound** - Not supported via HID
-**Battery Level**
-**Charging Status**
-**Connection Status**
-**Product Color**
## CLI Error Handling
The CLI application now provides clear error messages when attempting to use unsupported features:
```bash
# Example: Trying to control surround sound on Cloud II Wireless
$ ./hyper_headset_cli --surround_sound true
ERROR: Surround sound control is not supported on this device
Use the physical headset button or Windows audio settings to toggle surround sound.
# Example: Trying to mute on Cloud II Wireless
$ ./hyper_headset_cli --mute true
ERROR: Microphone mute control is not supported on this device (hardware button only)
```
## Tray Application UI
The system tray application now displays "(read-only)" markers next to features that cannot be controlled:
```
Battery level: 92%
Charging status: Not charging
Muted: false (read-only)
Automatic shutdown after: 20min
Side tone: false
Surround sound: true (read-only)
Connected: true
```
## Implementation Details
- Feature capabilities are checked once during device initialization via `init_capabilities()`
- Capability flags are stored in `DeviceState` structure
- CLI flags for unsupported features are hidden in the help menu
- CLI exits with error code 1 when attempting to use unsupported features
- Tray UI shows read-only markers based on device capabilities

View File

@@ -93,13 +93,15 @@ fn main() {
println!("sending automatic_shutdown packet");
device.prepare_write();
if let Err(err) = device.get_device_state().hid_device.write(&packet) {
println!("Failed to set automatic shutdown with error: {:?}", err)
eprintln!("Failed to set automatic shutdown with error: {:?}", err);
std::process::exit(1);
}
if let Some(events) = device.wait_for_updates(Duration::from_secs(1)) {
println!("{:?}", events);
}
} else {
println!("Automatic shutdown can't be enabled on this device")
eprintln!("ERROR: Automatic shutdown is not supported on this device");
std::process::exit(1);
}
} else {
println!("not sending automatic_shutdown packet");
@@ -109,10 +111,12 @@ fn main() {
if let Some(packet) = device.set_mute_packet(*mute) {
device.prepare_write();
if let Err(err) = device.get_device_state().hid_device.write(&packet) {
println!("Failed to mute with error: {:?}", err)
eprintln!("Failed to mute with error: {:?}", err);
std::process::exit(1);
}
} else {
println!("Can't mute this device")
eprintln!("ERROR: Microphone mute control is not supported on this device (hardware button only)");
std::process::exit(1);
}
}
@@ -122,14 +126,16 @@ fn main() {
println!("sending enable_side_tone packet");
device.prepare_write();
if let Err(err) = device.get_device_state().hid_device.write(&packet) {
println!("Failed to enable side tone with error: {:?}", err)
eprintln!("Failed to enable side tone with error: {:?}", err);
std::process::exit(1);
}
std::thread::sleep(Duration::from_millis(50));
if let Some(events) = device.wait_for_updates(Duration::from_secs(1)) {
println!("{:?}", events);
}
} else {
println!("Can't enable side tone on this device")
eprintln!("ERROR: Side tone control is not supported on this device");
std::process::exit(1);
}
} else {
println!("not sending enable_side_tone packet");
@@ -139,10 +145,12 @@ fn main() {
if let Some(packet) = device.set_side_tone_volume_packet(*volume) {
device.prepare_write();
if let Err(err) = device.get_device_state().hid_device.write(&packet) {
println!("Failed to set side tone volume with error: {:?}", err)
eprintln!("Failed to set side tone volume with error: {:?}", err);
std::process::exit(1);
}
} else {
println!("Can't set side tone volume on this device")
eprintln!("ERROR: Side tone volume control is not supported on this device");
std::process::exit(1);
}
}
@@ -150,10 +158,12 @@ fn main() {
if let Some(packet) = device.set_voice_prompt_packet(*enable) {
device.prepare_write();
if let Err(err) = device.get_device_state().hid_device.write(&packet) {
println!("Failed to enable voice prompt with error: {:?}", err)
eprintln!("Failed to enable voice prompt with error: {:?}", err);
std::process::exit(1);
}
} else {
println!("Can't enable voice prompt on this device")
eprintln!("ERROR: Voice prompt control is not supported on this device");
std::process::exit(1);
}
}
@@ -161,10 +171,13 @@ fn main() {
if let Some(packet) = device.set_surround_sound_packet(*surround_sound) {
device.prepare_write();
if let Err(err) = device.get_device_state().hid_device.write(&packet) {
println!("Failed to set surround sound with error: {:?}", err)
eprintln!("Failed to set surround sound with error: {:?}", err);
std::process::exit(1);
}
} else {
println!("Can't change surround sound on this device")
eprintln!("ERROR: Surround sound control is not supported on this device");
eprintln!(" Use the physical headset button or Windows audio settings to toggle surround sound.");
std::process::exit(1);
}
}
@@ -172,10 +185,12 @@ fn main() {
if let Some(packet) = device.set_silent_mode_packet(*mute_playback) {
device.prepare_write();
if let Err(err) = device.get_device_state().hid_device.write(&packet) {
println!("Failed to mute playback with error: {:?}", err)
eprintln!("Failed to mute playback with error: {:?}", err);
std::process::exit(1);
}
} else {
println!("Can't mute playback on this device")
eprintln!("ERROR: Playback mute control is not supported on this device");
std::process::exit(1);
}
}

View File

@@ -25,27 +25,32 @@ pub fn connect_compatible_device() -> Result<Box<dyn Device>, DeviceError> {
.get_product_string()?
.ok_or(DeviceError::NoDeviceFound())?;
println!("Connecting to {}", name);
match (state.vendor_id, state.product_id) {
let mut device: Box<dyn Device> = match (state.vendor_id, state.product_id) {
(v, p)
if cloud_ii_wireless::VENDOR_IDS.contains(&v)
&& cloud_ii_wireless::PRODUCT_IDS.contains(&p) =>
{
Ok(Box::new(CloudIIWireless::new_from_state(state)))
Box::new(CloudIIWireless::new_from_state(state))
}
(v, p)
if cloud_ii_wireless_dts::VENDOR_IDS.contains(&v)
&& cloud_ii_wireless_dts::PRODUCT_IDS.contains(&p) =>
{
Ok(Box::new(CloudIIWirelessDTS::new_from_state(state)))
Box::new(CloudIIWirelessDTS::new_from_state(state))
}
(v, p)
if cloud_iii_wireless::VENDOR_IDS.contains(&v)
&& cloud_iii_wireless::PRODUCT_IDS.contains(&p) =>
{
Ok(Box::new(CloudIIIWireless::new_from_state(state)))
Box::new(CloudIIIWireless::new_from_state(state))
}
(_, _) => Err(DeviceError::NoDeviceFound()),
}
(_, _) => return Err(DeviceError::NoDeviceFound()),
};
// Initialize capability flags
device.init_capabilities();
Ok(device)
}
#[derive(Debug)]
@@ -67,6 +72,14 @@ pub struct DeviceState {
pub voice_prompt_on: Option<bool>,
pub connected: Option<bool>,
pub silent: Option<bool>,
// Capability flags - set once during device initialization
pub can_set_mute: bool,
pub can_set_surround_sound: bool,
pub can_set_side_tone: bool,
pub can_set_automatic_shutdown: bool,
pub can_set_side_tone_volume: bool,
pub can_set_voice_prompt: bool,
pub can_set_silent_mode: bool,
}
impl Display for DeviceState {
@@ -114,6 +127,14 @@ impl DeviceState {
voice_prompt_on: None,
connected: None,
silent: None,
// Capability flags - will be set by init_capabilities()
can_set_mute: false,
can_set_surround_sound: false,
can_set_side_tone: false,
can_set_automatic_shutdown: false,
can_set_side_tone_volume: false,
can_set_voice_prompt: false,
can_set_silent_mode: false,
})
}
@@ -123,51 +144,79 @@ impl DeviceState {
"Battery level:",
self.battery_level.map(|l| l.to_string()),
"%",
false, // read-only flag (not applicable for read-only fields)
),
("Charging status:", self.charging.map(|c| c.to_string()), ""),
("Muted:", self.muted.map(|c| c.to_string()), ""),
(
"Charging status:",
self.charging.map(|c| c.to_string()),
"",
false,
),
("Muted:", self.muted.map(|c| c.to_string()), "", false),
(
"Mic connected:",
self.mic_connected.map(|c| c.to_string()),
"",
false,
),
(
"Automatic shutdown after:",
self.automatic_shutdown_after
.map(|c| (c.as_secs() / 60).to_string()),
"min",
false,
),
(
"pairing info:",
self.pairing_info.map(|c| c.to_string()),
"",
false,
),
(
"product color:",
self.product_color.map(|c| c.to_string()),
"",
false,
),
(
"Side tone:",
self.side_tone_on.map(|c| c.to_string()),
"",
false,
),
("Side tone:", self.side_tone_on.map(|c| c.to_string()), ""),
(
"Side tone volume:",
self.side_tone_volume.map(|c| c.to_string()),
"",
false,
),
(
"Surround sound:",
self.surround_sound.map(|c| c.to_string()),
"",
false,
),
(
"Voice prompt:",
self.voice_prompt_on.map(|c| c.to_string()),
"",
false,
),
(
"Connected:",
self.connected.map(|c| c.to_string()),
"",
false,
),
(
"Playback muted:",
self.silent.map(|c| c.to_string()),
"",
false,
),
("Connected:", self.connected.map(|c| c.to_string()), ""),
("Playback muted:", self.silent.map(|c| c.to_string()), ""),
];
data.iter()
.filter_map(|(prefix, data, suffix)| {
.filter_map(|(prefix, data, suffix, _)| {
if let Some(data) = data {
Some(format!("{:<padding$} {}{}", prefix, data, suffix))
} else {
@@ -178,6 +227,104 @@ impl DeviceState {
.join("\n")
}
pub fn to_string_with_readonly_info(&self, padding: usize) -> String {
let data = [
(
"Battery level:",
self.battery_level.map(|l| l.to_string()),
"%",
false,
),
(
"Charging status:",
self.charging.map(|c| c.to_string()),
"",
false,
),
(
"Muted:",
self.muted.map(|c| c.to_string()),
"",
!self.can_set_mute,
),
(
"Mic connected:",
self.mic_connected.map(|c| c.to_string()),
"",
false,
),
(
"Automatic shutdown after:",
self.automatic_shutdown_after
.map(|c| (c.as_secs() / 60).to_string()),
"min",
!self.can_set_automatic_shutdown,
),
(
"pairing info:",
self.pairing_info.map(|c| c.to_string()),
"",
false,
),
(
"product color:",
self.product_color.map(|c| c.to_string()),
"",
false,
),
(
"Side tone:",
self.side_tone_on.map(|c| c.to_string()),
"",
!self.can_set_side_tone,
),
(
"Side tone volume:",
self.side_tone_volume.map(|c| c.to_string()),
"",
!self.can_set_side_tone_volume,
),
(
"Surround sound:",
self.surround_sound.map(|c| c.to_string()),
"",
!self.can_set_surround_sound,
),
(
"Voice prompt:",
self.voice_prompt_on.map(|c| c.to_string()),
"",
!self.can_set_voice_prompt,
),
(
"Connected:",
self.connected.map(|c| c.to_string()),
"",
false,
),
(
"Playback muted:",
self.silent.map(|c| c.to_string()),
"",
!self.can_set_silent_mode,
),
];
data.iter()
.filter_map(|(prefix, data, suffix, readonly)| {
if let Some(data) = data {
let readonly_marker = if *readonly { " (read-only)" } else { "" };
Some(format!(
"{:<padding$} {}{}{}",
prefix, data, suffix, readonly_marker
))
} else {
None
}
})
.collect::<Vec<String>>()
.join("\n")
}
fn update_self_with_event(&mut self, event: &DeviceEvent) {
match event {
DeviceEvent::BatterLevel(level) => self.battery_level = Some(*level),
@@ -342,6 +489,53 @@ pub trait Device {
fn get_device_state(&self) -> &DeviceState;
fn get_device_state_mut(&mut self) -> &mut DeviceState;
fn prepare_write(&mut self) {}
// Helper methods to check if features are writable
fn can_set_mute(&self) -> bool {
self.set_mute_packet(false).is_some()
}
fn can_set_surround_sound(&self) -> bool {
self.set_surround_sound_packet(false).is_some()
}
fn can_set_side_tone(&self) -> bool {
self.set_side_tone_packet(false).is_some()
}
fn can_set_automatic_shutdown(&self) -> bool {
self.set_automatic_shut_down_packet(Duration::from_secs(0))
.is_some()
}
fn can_set_side_tone_volume(&self) -> bool {
self.set_side_tone_volume_packet(0).is_some()
}
fn can_set_voice_prompt(&self) -> bool {
self.set_voice_prompt_packet(false).is_some()
}
fn can_set_silent_mode(&self) -> bool {
self.set_silent_mode_packet(false).is_some()
}
// Initialize capability flags in device state
fn init_capabilities(&mut self) {
// Collect capabilities first to avoid borrowing conflicts
let can_set_mute = self.can_set_mute();
let can_set_surround_sound = self.can_set_surround_sound();
let can_set_side_tone = self.can_set_side_tone();
let can_set_automatic_shutdown = self.can_set_automatic_shutdown();
let can_set_side_tone_volume = self.can_set_side_tone_volume();
let can_set_voice_prompt = self.can_set_voice_prompt();
let can_set_silent_mode = self.can_set_silent_mode();
// Now set them in device state
let state = self.get_device_state_mut();
state.can_set_mute = can_set_mute;
state.can_set_surround_sound = can_set_surround_sound;
state.can_set_side_tone = can_set_side_tone;
state.can_set_automatic_shutdown = can_set_automatic_shutdown;
state.can_set_side_tone_volume = can_set_side_tone_volume;
state.can_set_voice_prompt = can_set_voice_prompt;
state.can_set_silent_mode = can_set_silent_mode;
}
fn execute_headset_specific_functionality(&mut self) -> Result<(), DeviceError> {
Ok(())
}

View File

@@ -23,7 +23,7 @@ impl TrayHandler {
device_state.device_name.clone(),
),
Some(true) => (
device_state.to_string_with_padding(0),
device_state.to_string_with_readonly_info(0),
device_state.device_name.clone(),
),
};