From 352180568d9467bcfa0790f79ed20e4af772bbe4 Mon Sep 17 00:00:00 2001 From: Lennard Kittner Date: Wed, 18 Mar 2026 19:32:55 +0100 Subject: [PATCH] Refactor changing headset properties Tray app can now change headset properties --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/bin/hyper_headset_cli.rs | 101 +---- src/devices/cloud_ii_core_wireless.rs | 4 +- src/devices/cloud_ii_wireless.rs | 4 +- src/devices/cloud_ii_wireless_dts.rs | 4 +- src/devices/mod.rs | 574 +++++++++++++++++++------- src/main.rs | 42 +- src/status_tray.rs | 221 ++++++++-- 9 files changed, 646 insertions(+), 308 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 761d9da..c0309e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,7 +401,7 @@ dependencies = [ [[package]] name = "hyper_headset" -version = "1.5.3" +version = "1.6.0" dependencies = [ "clap 4.5.58", "dialog", diff --git a/Cargo.toml b/Cargo.toml index 4098dc3..5a042f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyper_headset" -version = "1.5.3" +version = "1.6.0" edition = "2021" authors = ["Lennard Kittner"] description = "A CLI and tray application for monitoring and managing HyperX headsets." diff --git a/src/bin/hyper_headset_cli.rs b/src/bin/hyper_headset_cli.rs index 91771f9..eefa3e9 100644 --- a/src/bin/hyper_headset_cli.rs +++ b/src/bin/hyper_headset_cli.rs @@ -1,7 +1,7 @@ use std::time::Duration; use clap::{Arg, Command}; -use hyper_headset::devices::connect_compatible_device; +use hyper_headset::devices::{connect_compatible_device, DeviceEvent}; const SHOW_ALL_OPTIONS: bool = false; @@ -111,110 +111,45 @@ fn main() { ) .get_matches(); + let mut commands = Vec::new(); if let Some(delay) = matches.get_one::("automatic_shutdown") { let delay = *delay as u64; - if let Some(packet) = - device.set_automatic_shut_down_packet(Duration::from_secs(delay * 60u64)) - { - device.prepare_write(); - if let Err(err) = device.get_device_state().hid_device.write(&packet) { - eprintln!("Failed to set automatic shutdown with error: {:?}", err); - std::process::exit(1); - } - } else { - eprintln!("ERROR: Automatic shutdown is not supported on this device"); - std::process::exit(1); - } + commands.push(DeviceEvent::AutomaticShutdownAfter(Duration::from_secs( + delay * 60u64, + ))); } if let Some(mute) = matches.get_one::("mute") { - if let Some(packet) = device.set_mute_packet(*mute) { - device.prepare_write(); - if let Err(err) = device.get_device_state().hid_device.write(&packet) { - eprintln!("Failed to mute with error: {:?}", err); - std::process::exit(1); - } - } else { - eprintln!("ERROR: Microphone mute control is not supported on this device (hardware button only)"); - std::process::exit(1); - } + commands.push(DeviceEvent::Muted(*mute)); } if let Some(enable) = matches.get_one::("enable_side_tone") { - if let Some(packet) = device.set_side_tone_packet(*enable) { - device.prepare_write(); - if let Err(err) = device.get_device_state().hid_device.write(&packet) { - eprintln!("Failed to enable side tone with error: {:?}", err); - std::process::exit(1); - } - } else { - eprintln!("ERROR: Side tone control is not supported on this device"); - std::process::exit(1); - } + commands.push(DeviceEvent::SideToneOn(*enable)); } if let Some(volume) = matches.get_one::("side_tone_volume") { - 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) { - eprintln!("Failed to set side tone volume with error: {:?}", err); - std::process::exit(1); - } - } else { - eprintln!("ERROR: Side tone volume control is not supported on this device"); - std::process::exit(1); - } + commands.push(DeviceEvent::SideToneVolume(*volume)); } if let Some(enable) = matches.get_one::("enable_voice_prompt") { - 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) { - eprintln!("Failed to enable voice prompt with error: {:?}", err); - std::process::exit(1); - } - } else { - eprintln!("ERROR: Voice prompt control is not supported on this device"); - std::process::exit(1); - } + commands.push(DeviceEvent::VoicePrompt(*enable)); } if let Some(surround_sound) = matches.get_one::("surround_sound") { - 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) { - eprintln!("Failed to set surround sound with error: {:?}", err); - std::process::exit(1); - } - } else { - 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); - } + commands.push(DeviceEvent::SurroundSound(*surround_sound)); } if let Some(mute_playback) = matches.get_one::("mute_playback") { - 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) { - eprintln!("Failed to mute playback with error: {:?}", err); - std::process::exit(1); - } - } else { - eprintln!("ERROR: Playback mute control is not supported on this device"); - std::process::exit(1); - } + commands.push(DeviceEvent::Silent(*mute_playback)); } if let Some(activate) = matches.get_one::("activate_noise_gate") { - if let Some(packet) = device.set_noise_gate_packet(*activate) { - device.prepare_write(); - if let Err(err) = device.get_device_state().hid_device.write(&packet) { - eprintln!("Failed to activate noise gate with error: {:?}", err); - std::process::exit(1); - } - } else { - eprintln!("ERROR: Activating noise gate is not supported on this device"); + commands.push(DeviceEvent::NoiseGateActive(*activate)); + } + + for command in commands { + if let Err(e) = device.try_apply(command) { + eprintln!("{e}"); std::process::exit(1); } } @@ -233,5 +168,5 @@ fn main() { eprintln!("{error}"); std::process::exit(1); }; - println!("{}", device.get_device_state()); + println!("{}", device.get_device_state().device_properties); } diff --git a/src/devices/cloud_ii_core_wireless.rs b/src/devices/cloud_ii_core_wireless.rs index 22faad7..1c1bfa7 100644 --- a/src/devices/cloud_ii_core_wireless.rs +++ b/src/devices/cloud_ii_core_wireless.rs @@ -45,13 +45,13 @@ pub struct CloudIICoreWireless { impl CloudIICoreWireless { pub fn new_from_state(state: DeviceState) -> Self { let mut state = state; - state.connected = Some(true); + state.device_properties.connected = Some(true); CloudIICoreWireless { state } } pub fn new() -> Result { let mut state = DeviceState::new(&PRODUCT_IDS, &VENDOR_IDS)?; - state.connected = Some(true); + state.device_properties.connected = Some(true); Ok(CloudIICoreWireless { state }) } } diff --git a/src/devices/cloud_ii_wireless.rs b/src/devices/cloud_ii_wireless.rs index 43ea8e9..3e7b9ac 100644 --- a/src/devices/cloud_ii_wireless.rs +++ b/src/devices/cloud_ii_wireless.rs @@ -48,13 +48,13 @@ pub struct CloudIIWireless { impl CloudIIWireless { pub fn new_from_state(state: DeviceState) -> Self { let mut tmp_state = state; - tmp_state.connected = Some(true); + tmp_state.device_properties.connected = Some(true); CloudIIWireless { state: tmp_state } } pub fn new() -> Result { let mut state = DeviceState::new(&PRODUCT_IDS, &VENDOR_IDS)?; - state.connected = Some(true); + state.device_properties.connected = Some(true); Ok(CloudIIWireless { state }) } } diff --git a/src/devices/cloud_ii_wireless_dts.rs b/src/devices/cloud_ii_wireless_dts.rs index eabfcc3..617aef8 100644 --- a/src/devices/cloud_ii_wireless_dts.rs +++ b/src/devices/cloud_ii_wireless_dts.rs @@ -49,13 +49,13 @@ pub struct CloudIIWirelessDTS { impl CloudIIWirelessDTS { pub fn new_from_state(state: DeviceState) -> Self { let mut state = state; - state.connected = Some(true); + state.device_properties.connected = Some(true); CloudIIWirelessDTS { state } } pub fn new() -> Result { let mut state = DeviceState::new(&PRODUCT_IDS, &VENDOR_IDS)?; - state.connected = Some(true); + state.device_properties.connected = Some(true); Ok(CloudIIWirelessDTS { state }) } } diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 7faec16..3744dc4 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -14,7 +14,11 @@ use crate::{ }, }; use hidapi::{HidApi, HidDevice, HidError}; -use std::{collections::HashSet, fmt::Display, time::Duration}; +use std::{ + collections::HashSet, + fmt::{Debug, Display}, + time::Duration, +}; use thistermination::TerminationFull; const PASSIVE_REFRESH_TIME_OUT: Duration = Duration::from_secs(2); @@ -61,7 +65,7 @@ const DEVICE_REGISTER: &[DeviceEntry] = &[ ]; const RESPONSE_BUFFER_SIZE: usize = 256; -const RESPONSE_DELAY: Duration = Duration::from_millis(50); +pub const RESPONSE_DELAY: Duration = Duration::from_millis(50); pub fn connect_compatible_device() -> Result, DeviceError> { let all_product_ids: Vec = DEVICE_REGISTER @@ -82,7 +86,8 @@ pub fn connect_compatible_device() -> Result, DeviceError> { let entry = DEVICE_REGISTER .iter() .find(|e| { - e.vendor_ids.contains(&state.vendor_id) && e.product_ids.contains(&state.product_id) + e.vendor_ids.contains(&state.device_properties.vendor_id) + && e.product_ids.contains(&state.device_properties.product_id) }) .ok_or(DeviceError::NoDeviceFound())?; @@ -95,6 +100,11 @@ pub fn connect_compatible_device() -> Result, DeviceError> { #[derive(Debug)] pub struct DeviceState { pub hid_device: HidDevice, + pub device_properties: DeviceProperties, +} + +#[derive(Debug, Clone)] +pub struct DeviceProperties { pub product_id: u16, pub vendor_id: u16, pub device_name: Option, @@ -124,7 +134,7 @@ pub struct DeviceState { pub can_set_noise_gate: bool, } -impl Display for DeviceState { +impl Display for DeviceProperties { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.to_string_with_readonly_info(25)) } @@ -202,24 +212,98 @@ impl DeviceState { let device_name = hid_device.get_product_string()?; Ok(DeviceState { hid_device, + device_properties: DeviceProperties::new(product_id, vendor_id, device_name), + }) + } + + fn update_self_with_event(&mut self, event: &DeviceEvent) { + match event { + DeviceEvent::BatterLevel(level) => self.device_properties.battery_level = Some(*level), + DeviceEvent::Charging(status) => self.device_properties.charging = Some(*status), + DeviceEvent::Muted(status) => self.device_properties.muted = Some(*status), + DeviceEvent::MicConnected(status) => { + self.device_properties.mic_connected = Some(*status) + } + DeviceEvent::AutomaticShutdownAfter(duration) => { + self.device_properties.automatic_shutdown_after = Some(*duration) + } + DeviceEvent::PairingInfo(info) => self.device_properties.pairing_info = Some(*info), + DeviceEvent::ProductColor(color) => self.device_properties.product_color = Some(*color), + DeviceEvent::SideToneOn(side) => self.device_properties.side_tone_on = Some(*side), + DeviceEvent::SideToneVolume(volume) => { + self.device_properties.side_tone_volume = Some(*volume) + } + DeviceEvent::SurroundSound(status) => { + self.device_properties.surround_sound = Some(*status) + } + DeviceEvent::VoicePrompt(on) => self.device_properties.voice_prompt_on = Some(*on), + DeviceEvent::WirelessConnected(connected) => { + self.device_properties.connected = Some(*connected) + } + DeviceEvent::Silent(silent) => self.device_properties.silent = Some(*silent), + DeviceEvent::RequireSIRKReset(_reset) => { + debug_println!("requested SIRK reset {_reset}"); + } + DeviceEvent::NoiseGateActive(on) => { + self.device_properties.noise_gate_active = Some(*on) + } + }; + } +} + +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub enum PropertyType { + ReadOnly, + AlwaysReadOnly, + ReadWrite, +} + +#[derive(Debug)] +pub enum PropertyDescriptorWrapper { + Int(PropertyDescriptor, &'static [u8]), + Bool(PropertyDescriptor), + String(PropertyDescriptor), +} + +pub struct PropertyDescriptor { + pub prefix: &'static str, + pub data: Option, + pub suffix: &'static str, + pub property_type: PropertyType, + pub create_event: &'static dyn Fn(T) -> Option, +} + +impl Debug for PropertyDescriptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PropertyDescriptor") + .field("prefix", &self.prefix) + .field("data", &self.data) + .field("suffix", &self.suffix) + .field("property_type", &self.property_type) + .finish() + } +} + +impl DeviceProperties { + pub fn new(product_id: u16, vendor_id: u16, device_name: Option) -> DeviceProperties { + DeviceProperties { product_id, vendor_id, device_name, - charging: None, battery_level: None, + charging: None, muted: None, - surround_sound: None, mic_connected: None, automatic_shutdown_after: None, pairing_info: None, product_color: None, side_tone_on: None, side_tone_volume: None, + surround_sound: None, voice_prompt_on: None, connected: None, silent: None, noise_gate_active: None, - // Capability flags - will be set by init_capabilities() can_set_mute: false, can_set_surround_sound: false, can_set_side_tone: false, @@ -229,103 +313,183 @@ impl DeviceState { can_set_silent_mode: false, can_set_equalizer: false, can_set_noise_gate: false, - }) + } } - fn get_display_data(&self) -> Vec<(&str, Option, &str, bool)> { + pub fn get_properties(&self) -> Vec { vec![ - ( - "Battery level:", - self.battery_level.map(|l| l.to_string()), - "%", - false, + PropertyDescriptorWrapper::String(PropertyDescriptor { + prefix: "Charging status:", + data: self.charging.map(|c| c.to_string()), + suffix: "", + property_type: PropertyType::AlwaysReadOnly, + create_event: &|_| None, + }), + PropertyDescriptorWrapper::Int( + PropertyDescriptor { + prefix: "Battery level:", + data: self.battery_level, + suffix: "%", + property_type: PropertyType::AlwaysReadOnly, + create_event: &|_| None, + }, + &[], ), - ( - "Charging status:", - self.charging.map(|c| c.to_string()), - "", - false, + PropertyDescriptorWrapper::Bool(PropertyDescriptor { + prefix: "Muted:", + data: self.muted, + suffix: "", + property_type: if self.can_set_mute { + PropertyType::ReadWrite + } else { + PropertyType::ReadOnly + }, + create_event: &move |mute| Some(DeviceEvent::Muted(mute)), + }), + PropertyDescriptorWrapper::Bool(PropertyDescriptor { + prefix: "Mic connected:", + data: self.mic_connected, + suffix: "", + property_type: PropertyType::AlwaysReadOnly, + create_event: &|_| None, + }), + PropertyDescriptorWrapper::Int( + PropertyDescriptor { + prefix: "Automatic shutdown after:", + data: self + .automatic_shutdown_after + .map(|t| (t.as_secs() / 60) as u8), + suffix: "min", + property_type: if self.can_set_mute { + PropertyType::ReadWrite + } else { + PropertyType::ReadOnly + }, + create_event: &|t| { + Some(DeviceEvent::AutomaticShutdownAfter(Duration::from_secs( + t as u64 * 60, + ))) + }, + }, + &[0, 5, 10, 15, 20, 30, 40, 60], ), - ( - "Muted:", - self.muted.map(|c| c.to_string()), - "", - !self.can_set_mute, + PropertyDescriptorWrapper::Int( + PropertyDescriptor { + prefix: "Pairing info:", + data: self.pairing_info, + suffix: "", + property_type: PropertyType::AlwaysReadOnly, + create_event: &|_| None, + }, + &[], ), - ( - "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, - ), - ( - "Playback muted:", - self.silent.map(|c| c.to_string()), - "", - !self.can_set_silent_mode, - ), - ( - "Noise gate active:", - self.noise_gate_active.map(|c| c.to_string()), - "", - !self.can_set_noise_gate, - ), - ( - "Connected:", - self.connected.map(|c| c.to_string()), - "", - false, + PropertyDescriptorWrapper::String(PropertyDescriptor { + prefix: "Product color:", + data: self.product_color.map(|c| c.to_string()), + suffix: "", + property_type: PropertyType::AlwaysReadOnly, + create_event: &|_| None, + }), + PropertyDescriptorWrapper::Bool(PropertyDescriptor { + prefix: "Side tone:", + data: self.side_tone_on, + suffix: "", + property_type: if self.can_set_side_tone { + PropertyType::ReadWrite + } else { + PropertyType::ReadOnly + }, + create_event: &move |enable| Some(DeviceEvent::SideToneOn(enable)), + }), + PropertyDescriptorWrapper::Int( + PropertyDescriptor { + prefix: "Side tone volume:", + data: self.side_tone_volume, + suffix: "", + property_type: if self.can_set_side_tone_volume { + PropertyType::ReadWrite + } else { + PropertyType::ReadOnly + }, + create_event: &|v| Some(DeviceEvent::SideToneVolume(v)), + }, + &[0, 25, 50, 75, 100, 125, 150, 175, 200, 225, 250], ), + PropertyDescriptorWrapper::Bool(PropertyDescriptor { + prefix: "Surround sound:", + data: self.surround_sound, + suffix: "", + property_type: if self.can_set_surround_sound { + PropertyType::ReadWrite + } else { + PropertyType::ReadOnly + }, + create_event: &move |enable| Some(DeviceEvent::SurroundSound(enable)), + }), + PropertyDescriptorWrapper::Bool(PropertyDescriptor { + prefix: "Voice prompt:", + data: self.voice_prompt_on, + suffix: "", + property_type: if self.can_set_voice_prompt { + PropertyType::ReadWrite + } else { + PropertyType::ReadOnly + }, + create_event: &move |enable| Some(DeviceEvent::VoicePrompt(enable)), + }), + PropertyDescriptorWrapper::Bool(PropertyDescriptor { + prefix: "Playback muted:", + data: self.silent, + suffix: "", + property_type: if self.can_set_silent_mode { + PropertyType::ReadWrite + } else { + PropertyType::ReadOnly + }, + create_event: &move |enable| Some(DeviceEvent::Silent(enable)), + }), + PropertyDescriptorWrapper::Bool(PropertyDescriptor { + prefix: "Noise gate active:", + data: self.noise_gate_active, + suffix: "", + property_type: if self.can_set_noise_gate { + PropertyType::ReadWrite + } else { + PropertyType::ReadOnly + }, + create_event: &move |enable| Some(DeviceEvent::NoiseGateActive(enable)), + }), + PropertyDescriptorWrapper::Bool(PropertyDescriptor { + prefix: "Connected:", + data: self.connected, + suffix: "", + property_type: PropertyType::AlwaysReadOnly, + create_event: &|_| None, + }), ] } pub fn to_string_with_padding(&self, padding: usize) -> String { - self.get_display_data() + self.get_properties() .iter() - .filter_map(|(prefix, data, suffix, _)| { + .filter_map(|prop| { + let (prefix, data, suffix) = match prop { + PropertyDescriptorWrapper::Int(property_descriptor, _) => ( + property_descriptor.prefix, + &property_descriptor.data.map(|v| v.to_string()), + property_descriptor.suffix, + ), + PropertyDescriptorWrapper::Bool(property_descriptor) => ( + property_descriptor.prefix, + &property_descriptor.data.map(|v| v.to_string()), + property_descriptor.suffix, + ), + PropertyDescriptorWrapper::String(property_descriptor) => ( + property_descriptor.prefix, + &property_descriptor.data, + property_descriptor.suffix, + ), + }; data.as_ref() .map(|data| format!("{: String { - self.get_display_data() + self.get_properties() .iter() - .filter_map(|(prefix, data, suffix, readonly)| { - if let Some(data) = data { - let readonly_marker = if *readonly { " (read-only)" } else { "" }; - Some(format!( - "{: ( + property_descriptor.prefix, + &property_descriptor.data.map(|v| v.to_string()), + property_descriptor.suffix, + property_descriptor.property_type, + ), + PropertyDescriptorWrapper::Bool(property_descriptor) => ( + property_descriptor.prefix, + &property_descriptor.data.map(|v| v.to_string()), + property_descriptor.suffix, + property_descriptor.property_type, + ), + PropertyDescriptorWrapper::String(property_descriptor) => ( + property_descriptor.prefix, + &property_descriptor.data, + property_descriptor.suffix, + property_descriptor.property_type, + ), + }; + + data.as_ref().map(|data| { + let readonly_marker = if property_type == PropertyType::ReadOnly { + " (read-only)" + } else { + "" + }; + format!("{:>() .join("\n") } - - fn update_self_with_event(&mut self, event: &DeviceEvent) { - match event { - DeviceEvent::BatterLevel(level) => self.battery_level = Some(*level), - DeviceEvent::Charging(status) => self.charging = Some(*status), - DeviceEvent::Muted(status) => self.muted = Some(*status), - DeviceEvent::MicConnected(status) => self.mic_connected = Some(*status), - DeviceEvent::AutomaticShutdownAfter(duration) => { - self.automatic_shutdown_after = Some(*duration) - } - DeviceEvent::PairingInfo(info) => self.pairing_info = Some(*info), - DeviceEvent::ProductColor(color) => self.product_color = Some(*color), - DeviceEvent::SideToneOn(side) => self.side_tone_on = Some(*side), - DeviceEvent::SideToneVolume(volume) => self.side_tone_volume = Some(*volume), - DeviceEvent::SurroundSound(status) => self.surround_sound = Some(*status), - DeviceEvent::VoicePrompt(on) => self.voice_prompt_on = Some(*on), - DeviceEvent::WirelessConnected(connected) => self.connected = Some(*connected), - DeviceEvent::Silent(silent) => self.silent = Some(*silent), - DeviceEvent::RequireSIRKReset(reset) => { - debug_println!("requested SIRK reset {reset}"); - } - DeviceEvent::NoiseGateActive(on) => self.noise_gate_active = Some(*on), - }; - } - - pub fn clear_state(&mut self) { - self.charging = None; - self.battery_level = None; - self.muted = None; - self.surround_sound = None; - self.mic_connected = None; - self.automatic_shutdown_after = None; - self.pairing_info = None; - self.product_color = None; - self.side_tone_on = None; - self.side_tone_volume = None; - self.voice_prompt_on = None; - self.connected = None; - self.silent = None; - self.noise_gate_active = None; - } } #[derive(TerminationFull)] @@ -460,7 +603,7 @@ impl From for Color { } } -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum ChargingStatus { NotCharging, Charging, @@ -583,20 +726,21 @@ pub trait Device { // 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; - state.can_set_equalizer = can_set_equalizer; - state.can_set_noise_gate = can_set_noise_gate; + state.device_properties.can_set_mute = can_set_mute; + state.device_properties.can_set_surround_sound = can_set_surround_sound; + state.device_properties.can_set_side_tone = can_set_side_tone; + state.device_properties.can_set_automatic_shutdown = can_set_automatic_shutdown; + state.device_properties.can_set_side_tone_volume = can_set_side_tone_volume; + state.device_properties.can_set_voice_prompt = can_set_voice_prompt; + state.device_properties.can_set_silent_mode = can_set_silent_mode; + state.device_properties.can_set_equalizer = can_set_equalizer; + state.device_properties.can_set_noise_gate = can_set_noise_gate; } fn execute_headset_specific_functionality(&mut self) -> Result<(), DeviceError> { Ok(()) } + fn wait_for_updates(&mut self, duration: Duration) -> Option> { let mut buf = self.get_response_buffer(); let res = self @@ -646,7 +790,10 @@ pub trait Device { } responded = true; } - if !matches!(self.get_device_state().connected, Some(true)) { + if !matches!( + self.get_device_state().device_properties.connected, + Some(true) + ) { break; } } @@ -681,4 +828,121 @@ pub trait Device { Ok(()) } + + fn try_apply(&mut self, command: DeviceEvent) -> Result<(), String> { + match command { + DeviceEvent::AutomaticShutdownAfter(delay) => { + if let Some(packet) = self.set_automatic_shut_down_packet(delay) { + self.prepare_write(); + if let Err(err) = self.get_device_state().hid_device.write(&packet) { + Err(format!( + "Failed to set automatic shutdown with error: {:?}", + err + ))?; + } + } else { + Err("ERROR: Automatic shutdown is not supported on this device".to_string())?; + } + } + DeviceEvent::Muted(mute) => { + if let Some(packet) = self.set_mute_packet(mute) { + self.prepare_write(); + if let Err(err) = self.get_device_state().hid_device.write(&packet) { + Err(format!("Failed to mute with error: {:?}", err))?; + } + } else { + Err("ERROR: Microphone mute control is not supported on this device (hardware button only)")?; + } + } + DeviceEvent::SideToneOn(enable) => { + if let Some(packet) = self.set_side_tone_packet(enable) { + self.prepare_write(); + if let Err(err) = self.get_device_state().hid_device.write(&packet) { + Err(format!("Failed to enable side tone with error: {:?}", err))?; + } + } else { + Err("ERROR: Side tone control is not supported on this device".to_string())?; + } + } + DeviceEvent::SideToneVolume(volume) => { + if let Some(packet) = self.set_side_tone_volume_packet(volume) { + self.prepare_write(); + if let Err(err) = self.get_device_state().hid_device.write(&packet) { + Err(format!( + "Failed to set side tone volume with error: {:?}", + err + ))?; + } + } else { + Err( + "ERROR: Side tone volume control is not supported on this device" + .to_string(), + )?; + } + } + DeviceEvent::VoicePrompt(enable) => { + if let Some(packet) = self.set_voice_prompt_packet(enable) { + self.prepare_write(); + if let Err(err) = self.get_device_state().hid_device.write(&packet) { + Err(format!( + "Failed to enable voice prompt with error: {:?}", + err + ))?; + } + } else { + Err("ERROR: Voice prompt control is not supported on this device")?; + } + } + DeviceEvent::SurroundSound(surround_sound) => { + if let Some(packet) = self.set_surround_sound_packet(surround_sound) { + self.prepare_write(); + if let Err(err) = self.get_device_state().hid_device.write(&packet) { + Err(format!( + "Failed to set surround sound with error: {:?}", + err + ))?; + } + } else { + Err("ERROR: Surround sound control is not supported on this device")?; + } + } + DeviceEvent::Silent(mute_playback) => { + if let Some(packet) = self.set_silent_mode_packet(mute_playback) { + self.prepare_write(); + if let Err(err) = self.get_device_state().hid_device.write(&packet) { + Err(format!("Failed to mute playback with error: {:?}", err))?; + } + } else { + Err("ERROR: Playback mute control is not supported on this device")?; + } + } + DeviceEvent::NoiseGateActive(activate) => { + if let Some(packet) = self.set_noise_gate_packet(activate) { + self.prepare_write(); + if let Err(err) = self.get_device_state().hid_device.write(&packet) { + Err(format!( + "Failed to activate noise gate with error: {:?}", + err + ))?; + } + } else { + Err("ERROR: Activating noise gate is not supported on this device")?; + } + } + _ => (), + } + Ok(()) + } + + fn clear_state(&mut self) { + let product_id = self.get_device_state().device_properties.product_id; + let vendor_id = self.get_device_state().device_properties.vendor_id; + let device_name = self + .get_device_state() + .device_properties + .device_name + .clone(); + self.get_device_state_mut().device_properties = + DeviceProperties::new(product_id, vendor_id, device_name) + } } diff --git a/src/main.rs b/src/main.rs index c4d4f60..cfee6cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,5 @@ -#[cfg(target_os = "linux")] -use clap::{Arg, Command}; -#[cfg(target_os = "linux")] -use enigo::{Direction, Enigo, Key, Keyboard, Settings}; -#[cfg(target_os = "linux")] -use std::time::Duration; - #[cfg(target_os = "linux")] mod status_tray; -#[cfg(target_os = "linux")] -use hyper_headset::devices::connect_compatible_device; -#[cfg(target_os = "linux")] -use status_tray::{StatusTray, TrayHandler}; #[cfg(not(target_os = "linux"))] fn main() { @@ -19,6 +8,14 @@ fn main() { #[cfg(target_os = "linux")] fn main() { + use clap::{Arg, Command}; + use enigo::{Direction, Enigo, Key, Keyboard, Settings}; + use std::sync::mpsc; + use std::time::Duration; + + use hyper_headset::devices::connect_compatible_device; + use status_tray::{StatusTray, TrayHandler}; + #[cfg(target_os = "linux")] { use hyper_headset::act_as_askpass_handler; @@ -61,7 +58,8 @@ fn main() { let refresh_interval = *matches.get_one::("refresh_interval").unwrap_or(&3); let press_mute_key = *matches.get_one::("press_mute_key").unwrap_or(&true); let refresh_interval = Duration::from_secs(refresh_interval); - let tray_handler = TrayHandler::new(StatusTray::new()); + let (tx, rx) = mpsc::channel(); + let tray_handler = TrayHandler::new(StatusTray::new(tx)); loop { let mut device = loop { match connect_compatible_device() { @@ -77,11 +75,7 @@ fn main() { // Run loop let mut run_counter = 0; loop { - std::thread::sleep(refresh_interval); - // with the default refresh_interval the state is only actively queried every 3min - // querying the device to frequently can lead to instability - - let mute_state = device.get_device_state().muted; + let mute_state = device.get_device_state().device_properties.muted; match if run_counter % 30 == 0 { device.active_refresh_state() } else { @@ -94,12 +88,24 @@ fn main() { break; // try to reconnect } }; - if mute_state.is_some() && mute_state != device.get_device_state().muted { + if mute_state.is_some() + && mute_state != device.get_device_state().device_properties.muted + { //TODO: macOS and windows have to use another key if press_mute_key { enigo.key(Key::MicMute, Direction::Click).unwrap(); } } + + // with the default refresh_interval the state is only actively queried every 3min + // querying the device to frequently can lead to instability + let first = rx.recv_timeout(refresh_interval); + for command in first.into_iter().chain(rx.try_iter()) { + let _ = device.try_apply(command); + std::thread::sleep(hyper_headset::devices::RESPONSE_DELAY); + let _ = device.active_refresh_state(); + } + tray_handler.update(device.get_device_state()); run_counter += 1; } diff --git a/src/status_tray.rs b/src/status_tray.rs index 5b752de..c4c59ee 100644 --- a/src/status_tray.rs +++ b/src/status_tray.rs @@ -1,11 +1,17 @@ -use hyper_headset::devices::DeviceState; -use ksni::{menu::StandardItem, Handle, MenuItem, ToolTip, Tray, TrayService}; +use std::sync::mpsc::Sender; + +use hyper_headset::devices::{DeviceEvent, DeviceProperties, DeviceState, PropertyType}; +use ksni::{ + menu::{StandardItem, SubMenu}, + Handle, MenuItem, ToolTip, Tray, TrayService, +}; pub struct TrayHandler { handle: Handle, } const NO_COMPATIBLE_DEVICE: &str = "No compatible device found.\nIs the dongle plugged in?\nIf you are using Linux did you\nadd the Udev rules?"; +const HEADSET_NOT_CONNECTED: &str = "Headset is not connected"; impl TrayHandler { pub fn new(tray: StatusTray) -> Self { @@ -16,41 +22,28 @@ impl TrayHandler { } pub fn update(&self, device_state: &DeviceState) { - let (message, name) = match device_state.connected { - None => (NO_COMPATIBLE_DEVICE.to_string(), None), - Some(false) => ( - "Headset is not connected".to_string(), - device_state.device_name.clone(), - ), - Some(true) => ( - device_state.to_string_with_padding(0), - device_state.device_name.clone(), - ), - }; self.handle.update(|tray| { - tray.message = message; - tray.device_name = name; + tray.device_properties = Some(device_state.device_properties.clone()); }) } pub fn clear_state(&self) { self.handle.update(|tray| { - tray.message = NO_COMPATIBLE_DEVICE.to_string(); - tray.device_name = None; + tray.device_properties = None; }) } } pub struct StatusTray { - device_name: Option, - message: String, + device_properties: Option, + update_sender: Sender, } impl StatusTray { - pub fn new() -> Self { + pub fn new(update_sender: Sender) -> Self { StatusTray { - device_name: None, - message: NO_COMPATIBLE_DEVICE.to_string(), + device_properties: None, + update_sender, } } } @@ -59,43 +52,183 @@ impl Tray for StatusTray { fn id(&self) -> String { env!("CARGO_PKG_NAME").into() } + fn icon_name(&self) -> String { "audio-headset".into() } + fn tool_tip(&self) -> ToolTip { - let description = self - .message - .lines() - .filter(|l| !l.contains("Unknown")) - .collect::>() - .join("\n"); + let Some(device_properties) = self.device_properties.as_ref() else { + return ToolTip { + title: "Unknown".to_string(), + description: NO_COMPATIBLE_DEVICE.to_string(), + icon_name: "audio-headset".into(), + icon_pixmap: Vec::new(), + }; + }; + let description = if device_properties.connected.unwrap_or(false) { + device_properties + .to_string_with_padding(0) + .lines() + .filter(|l| !l.contains("Unknown")) + .collect::>() + .join("\n") + } else { + HEADSET_NOT_CONNECTED.to_string() + }; + ToolTip { - title: self.device_name.clone().unwrap_or("Unknown".to_string()), + title: device_properties + .device_name + .clone() + .unwrap_or("Unknown".to_string()), description, icon_name: "audio-headset".into(), icon_pixmap: Vec::new(), } } + fn menu(&self) -> Vec> { - let mut state_items: Vec> = self - .message - .lines() - .map(|line| { - StandardItem { - label: line.to_string(), - enabled: false, - ..Default::default() - } - .into() - }) - .collect(); - let exit = StandardItem { + let make_exit = || StandardItem { label: "Exit".into(), icon_name: "application-exit".into(), activate: Box::new(|_| std::process::exit(0)), ..Default::default() }; - state_items.push(exit.into()); - state_items + let mut menu_items: Vec> = Vec::new(); + + let Some(device_properties) = self.device_properties.as_ref() else { + menu_items.push( + StandardItem { + label: NO_COMPATIBLE_DEVICE.to_string(), + enabled: false, + ..Default::default() + } + .into(), + ); + menu_items.push(make_exit().into()); + return menu_items; + }; + + if !device_properties.connected.unwrap_or(false) { + menu_items.push( + StandardItem { + label: HEADSET_NOT_CONNECTED.to_string(), + enabled: false, + ..Default::default() + } + .into(), + ); + menu_items.push(make_exit().into()); + return menu_items; + } + for property in device_properties.get_properties() { + match property { + hyper_headset::devices::PropertyDescriptorWrapper::Int(property, []) => { + let Some(current_value) = property.data else { + continue; + }; + let create_event = property.create_event; + menu_items.push( + StandardItem { + label: format!( + "{} {}{}", + property.prefix, current_value, property.suffix + ), + enabled: false, + activate: Box::new(move |_| { + let _ = (create_event)(!current_value); + }), + ..Default::default() + } + .into(), + ); + } + hyper_headset::devices::PropertyDescriptorWrapper::Int(property, options) => { + let Some(current_value) = property.data else { + continue; + }; + let create_event = property.create_event; + let sub_menu = options + .iter() + .map(|val| { + let update_sender = self.update_sender.clone(); + StandardItem { + label: format!("{}{}", val, property.suffix), + enabled: property.property_type == PropertyType::ReadWrite + && property.data.is_some(), + activate: Box::new(move |_| { + if let Some(command) = (create_event)(*val) { + let _ = update_sender.send(command); + } + }), + ..Default::default() + } + .into() + }) + .collect(); + menu_items.push( + SubMenu { + label: format!( + "{} {}{}", + property.prefix, current_value, property.suffix + ), + enabled: property.property_type == PropertyType::ReadWrite + && property.data.is_some(), + submenu: sub_menu, + ..Default::default() + } + .into(), + ); + } + hyper_headset::devices::PropertyDescriptorWrapper::Bool(property) => { + let Some(current_value) = property.data else { + continue; + }; + let create_event = property.create_event; + let update_sender = self.update_sender.clone(); + menu_items.push( + StandardItem { + label: format!( + "{} {}{}", + property.prefix, current_value, property.suffix + ), + enabled: property.property_type == PropertyType::ReadWrite + && property.data.is_some(), + activate: Box::new(move |_| { + if let Some(command) = (create_event)(!current_value) { + let _ = update_sender.send(command); + } + }), + ..Default::default() + } + .into(), + ); + } + hyper_headset::devices::PropertyDescriptorWrapper::String(property) => { + let Some(current_value) = property.data else { + continue; + }; + let create_event = property.create_event; + menu_items.push( + StandardItem { + label: format!( + "{} {}{}", + property.prefix, current_value, property.suffix + ), + enabled: false, + activate: Box::new(move |_| { + let _ = (create_event)(String::new()); + }), + ..Default::default() + } + .into(), + ); + } + } + } + + menu_items.push(make_exit().into()); + menu_items } }