From 80976df212eac4f141f56cbeee140ba7a9f0159d Mon Sep 17 00:00:00 2001 From: Fabio Scaccabarozzi Date: Sun, 19 Oct 2025 18:21:31 +0100 Subject: [PATCH] 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. --- FEATURE_CAPABILITIES.md | 89 ++++++++++++++ src/bin/hyper_headset_cli.rs | 43 ++++--- src/devices/mod.rs | 218 +++++++++++++++++++++++++++++++++-- src/status_tray.rs | 2 +- 4 files changed, 325 insertions(+), 27 deletions(-) create mode 100644 FEATURE_CAPABILITIES.md diff --git a/FEATURE_CAPABILITIES.md b/FEATURE_CAPABILITIES.md new file mode 100644 index 0000000..26bcd16 --- /dev/null +++ b/FEATURE_CAPABILITIES.md @@ -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 diff --git a/src/bin/hyper_headset_cli.rs b/src/bin/hyper_headset_cli.rs index a254156..581cefb 100644 --- a/src/bin/hyper_headset_cli.rs +++ b/src/bin/hyper_headset_cli.rs @@ -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); } } diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 4c0e87d..0e69d85 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -25,27 +25,32 @@ pub fn connect_compatible_device() -> Result, DeviceError> { .get_product_string()? .ok_or(DeviceError::NoDeviceFound())?; println!("Connecting to {}", name); - match (state.vendor_id, state.product_id) { + let mut device: Box = 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, pub connected: Option, pub silent: Option, + // 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!("{: 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!( + "{:>() + .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(()) } diff --git a/src/status_tray.rs b/src/status_tray.rs index 0d7de69..bdf09c0 100644 --- a/src/status_tray.rs +++ b/src/status_tray.rs @@ -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(), ), };