linux-rust: add listening mode picker for airpods

This commit is contained in:
Kavish Devar
2025-11-08 22:01:55 +05:30
parent bf6630dbd1
commit 3853e8ec9a
6 changed files with 378 additions and 76 deletions

View File

@@ -1,6 +1,6 @@
use crate::bluetooth::aacp::{AACPManager, ProximityKeyType, AACPEvent, AirPodsLEKeys};
use crate::bluetooth::aacp::ControlCommandIdentifiers;
// use crate::bluetooth::att::ATTManager;
use crate::bluetooth::att::ATTManager;
use crate::media_controller::MediaController;
use bluer::Address;
use log::{debug, info, error};

View File

@@ -1,4 +1,5 @@
use std::fmt::Display;
use iced::widget::{combo_box, ComboBox};
use serde::{Deserialize, Serialize};
use crate::devices::airpods::AirPodsInformation;
use crate::devices::nothing::NothingInformation;
@@ -53,8 +54,50 @@ impl Display for DeviceState {
#[derive(Clone, Debug)]
pub struct AirPodsState {
pub device_name: String,
pub noise_control_mode: AirPodsNoiseControlMode,
pub noise_control_state: combo_box::State<AirPodsNoiseControlMode>,
pub conversation_awareness_enabled: bool,
pub personalized_volume_enabled: bool,
pub allow_off_mode: bool
}
#[derive(Clone, Debug)]
pub enum AirPodsNoiseControlMode {
Off,
NoiseCancellation,
Transparency,
Adaptive
}
impl Display for AirPodsNoiseControlMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AirPodsNoiseControlMode::Off => write!(f, "Off"),
AirPodsNoiseControlMode::NoiseCancellation => write!(f, "Noise Cancellation"),
AirPodsNoiseControlMode::Transparency => write!(f, "Transparency"),
AirPodsNoiseControlMode::Adaptive => write!(f, "Adaptive"),
}
}
}
impl AirPodsNoiseControlMode {
pub fn from_byte(value: &u8) -> Self {
match value {
0x01 => AirPodsNoiseControlMode::Off,
0x02 => AirPodsNoiseControlMode::NoiseCancellation,
0x03 => AirPodsNoiseControlMode::Transparency,
0x04 => AirPodsNoiseControlMode::Adaptive,
_ => AirPodsNoiseControlMode::Off,
}
}
pub fn to_byte(&self) -> u8 {
match self {
AirPodsNoiseControlMode::Off => 0x01,
AirPodsNoiseControlMode::NoiseCancellation => 0x02,
AirPodsNoiseControlMode::Transparency => 0x03,
AirPodsNoiseControlMode::Adaptive => 0x04,
}
}
}
#[derive(Clone, Debug)]

View File

@@ -123,18 +123,17 @@ async fn async_main(
Ok(device) => {
let name = device.name().await?.unwrap_or_else(|| "Unknown".to_string());
info!("Found connected AirPods: {}, initializing.", name);
let ui_tx_clone = ui_tx.clone();
ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(device.address().to_string())).unwrap();
let airpods_device = AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx_clone).await;
let airpods_device = AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx.clone()).await;
let mut managers = device_managers.write().await;
// let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone());
let dev_managers = DeviceManagers::with_aacp(airpods_device.aacp_manager.clone());
managers
.entry(device.address().to_string())
.or_insert(dev_managers)
.set_aacp(airpods_device.aacp_manager)
;
drop(managers)
.set_aacp(airpods_device.aacp_manager);
drop(managers);
ui_tx.send(BluetoothUIMessage::DeviceConnected(device.address().to_string())).unwrap();
}
Err(_) => {
info!("No connected AirPods found.");
@@ -150,16 +149,16 @@ async fn async_main(
let ui_tx_clone = ui_tx.clone();
let device_managers = device_managers.clone();
tokio::spawn(async move {
ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())).unwrap();
let mut managers = device_managers.write().await;
match type_ {
devices::enums::DeviceType::Nothing => {
let dev = devices::nothing::NothingDevice::new(device.address(), ui_tx_clone).await;
let dev = devices::nothing::NothingDevice::new(device.address(), ui_tx_clone.clone()).await;
let dev_managers = DeviceManagers::with_att(dev.att_manager.clone());
managers
.entry(addr_str)
.entry(addr_str.clone())
.or_insert(dev_managers)
.set_att(dev.att_manager);
ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str)).unwrap();
}
_ => {}
}
@@ -212,14 +211,14 @@ async fn async_main(
let device_managers = device_managers.clone();
tokio::spawn(async move {
let mut managers = device_managers.write().await;
ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())).unwrap();
let dev = devices::nothing::NothingDevice::new(addr, ui_tx_clone).await;
let dev = devices::nothing::NothingDevice::new(addr, ui_tx_clone.clone()).await;
let dev_managers = DeviceManagers::with_att(dev.att_manager.clone());
managers
.entry(addr_str)
.entry(addr_str.clone())
.or_insert(dev_managers)
.set_att(dev.att_manager);
drop(managers);
ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())).unwrap();
});
}
_ => {}
@@ -236,15 +235,16 @@ async fn async_main(
let ui_tx_clone = ui_tx.clone();
let device_managers = device_managers.clone();
tokio::spawn(async move {
ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())).unwrap();
let airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone).await;
let airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone.clone()).await;
let mut managers = device_managers.write().await;
// let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone());
let dev_managers = DeviceManagers::with_aacp(airpods_device.aacp_manager.clone());
managers
.entry(addr_str)
.entry(addr_str.clone())
.or_insert(dev_managers)
.set_aacp(airpods_device.aacp_manager);
drop(managers);
ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())).unwrap();
});
true
})?;

View File

@@ -1,15 +1,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::thread;
use iced::widget::{button, column, container, row, rule, text, text_input, toggler, Rule, Space};
use iced::widget::{button, column, combo_box, container, row, rule, text, text_input, toggler, Rule, Space};
use iced::{Background, Border, Center, Color, Length, Padding, Theme};
use iced::Alignment::End;
use iced::border::Radius;
use iced::overlay::menu;
use iced::widget::button::Style;
use iced::widget::rule::FillMode;
use log::error;
use tokio::runtime::Runtime;
use crate::bluetooth::aacp::{AACPManager, ControlCommandIdentifiers};
// use crate::bluetooth::att::ATTManager;
use crate::devices::enums::{AirPodsState, DeviceData, DeviceInformation, DeviceState};
use crate::ui::window::Message;
@@ -17,11 +19,11 @@ pub fn airpods_view<'a>(
mac: &'a str,
devices_list: &HashMap<String, DeviceData>,
state: &'a AirPodsState,
aacp_manager: Arc<AACPManager>
aacp_manager: Arc<AACPManager>,
// att_manager: Arc<ATTManager>
) -> iced::widget::Container<'a, Message> {
let mac = mac.to_string();
// order: name, noise control, press and hold config, call controls (not sure if why it might be needed, adding it just in case), audio (personalized volume, conversational awareness, adaptive audio slider), connection settings, microphone, head gestures (not adding this), off listening mode, device information
let aacp_manager_for_rename = aacp_manager.clone();
let rename_input = container(
row![
@@ -57,19 +59,23 @@ pub fn airpods_view<'a>(
}
)
.align_x(End)
.on_input( move |new_name| {
let aacp_manager = aacp_manager_for_rename.clone();
run_async_in_thread(
{
let new_name = new_name.clone();
async move {
aacp_manager.send_rename_packet(&new_name).await.expect("Failed to send rename packet");
.on_input({
let mac = mac.clone();
let state = state.clone();
move|new_name| {
let aacp_manager = aacp_manager_for_rename.clone();
run_async_in_thread(
{
let new_name = new_name.clone();
async move {
aacp_manager.send_rename_packet(&new_name).await.expect("Failed to send rename packet");
}
}
}
);
let mut state = state.clone();
state.device_name = new_name.clone();
Message::StateChanged(mac.to_string(), DeviceState::AirPods(state))
);
let mut state = state.clone();
state.device_name = new_name.clone();
Message::StateChanged(mac.to_string(), DeviceState::AirPods(state))
}
}
)
]
@@ -92,6 +98,104 @@ pub fn airpods_view<'a>(
}
);
let listening_mode = container(row![
text("Listening Mode").size(16).style(
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}
),
Space::with_width(Length::Fill),
{
let state_clone = state.clone();
let mac = mac.clone();
// this combo_box doesn't go really well with the design, but I am not writing my own dropdown menu for this
combo_box(
&state.noise_control_state,
"Select Listening Mode",
Some(&state.noise_control_mode.clone()),
{
let aacp_manager = aacp_manager.clone();
move |selected_mode| {
let aacp_manager = aacp_manager.clone();
let selected_mode_c = selected_mode.clone();
run_async_in_thread(
async move {
aacp_manager.send_control_command(
ControlCommandIdentifiers::ListeningMode,
&[selected_mode_c.to_byte()]
).await.expect("Failed to send Noise Control Mode command");
}
);
let mut state = state_clone.clone();
state.noise_control_mode = selected_mode.clone();
Message::StateChanged(mac.to_string(), DeviceState::AirPods(state))
}
}
)
.width(Length::from(200))
.input_style(
|theme: &Theme, _status| {
text_input::Style {
background: Background::Color(theme.palette().primary.scale_alpha(0.2)),
border: Border {
width: 1.0,
color: theme.palette().text.scale_alpha(0.3),
radius: Radius::from(4.0)
},
icon: Default::default(),
placeholder: theme.palette().text,
value: theme.palette().text,
selection: Default::default(),
}
}
)
.padding(Padding{
top: 5.0,
bottom: 5.0,
left: 10.0,
right: 10.0,
})
.menu_style(
|theme: &Theme| {
menu::Style {
background: Background::Color(theme.palette().background),
border: Border {
width: 1.0,
color: theme.palette().text,
radius: Radius::from(4.0)
},
text_color: theme.palette().text,
selected_text_color: theme.palette().text,
selected_background: Background::Color(theme.palette().primary.scale_alpha(0.3)),
}
}
)
}
]
.align_y(Center)
)
.padding(Padding{
top: 5.0,
bottom: 5.0,
left: 18.0,
right: 18.0,
})
.style(
|theme: &Theme| {
let mut style = container::Style::default();
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.5);
style.border = border.rounded(16);
style
}
);
let mac_audio = mac.clone();
let mac_information = mac.clone();
let audio_settings_col = column![
container(
text("Audio Settings").size(18).style(
@@ -126,20 +230,27 @@ pub fn airpods_view<'a>(
],
Space::with_width(Length::Fill),
toggler(state.personalized_volume_enabled)
.on_toggle(move |is_enabled| {
let aacp_manager = aacp_manager_pv.clone();
run_async_in_thread(
async move {
aacp_manager.send_control_command(
ControlCommandIdentifiers::AdaptiveVolumeConfig,
if is_enabled { &[0x01] } else { &[0x02] }
).await.expect("Failed to send Personalized Volume command");
}
);
let mut state = state.clone();
state.personalized_volume_enabled = is_enabled;
Message::StateChanged(mac.to_string(), DeviceState::AirPods(state))
})
.on_toggle(
{
let mac = mac_audio.clone();
let state = state.clone();
move |is_enabled| {
let aacp_manager = aacp_manager_pv.clone();
let mac = mac.clone();
run_async_in_thread(
async move {
aacp_manager.send_control_command(
ControlCommandIdentifiers::AdaptiveVolumeConfig,
if is_enabled { &[0x01] } else { &[0x02] }
).await.expect("Failed to send Personalized Volume command");
}
);
let mut state = state.clone();
state.personalized_volume_enabled = is_enabled;
Message::StateChanged(mac, DeviceState::AirPods(state))
}
}
)
.spacing(0)
.size(20)
]
@@ -182,7 +293,7 @@ pub fn airpods_view<'a>(
);
let mut state = state.clone();
state.conversation_awareness_enabled = is_enabled;
Message::StateChanged(mac.to_string(), DeviceState::AirPods(state))
Message::StateChanged(mac_audio.to_string(), DeviceState::AirPods(state))
})
.spacing(0)
.size(20)
@@ -211,9 +322,61 @@ pub fn airpods_view<'a>(
)
];
let off_listening_mode_toggle = {
let aacp_manager_olm = aacp_manager.clone();
let mac = mac.clone();
container(row![
column![
text("Off Listening Mode").size(16),
text("When this is on, AIrPods listening modes will include an Off option. Loud sound levels are not reduced when listening mode is set to Off.").size(12).style(
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
)
],
Space::with_width(Length::Fill),
toggler(state.allow_off_mode)
.on_toggle(move |is_enabled| {
let aacp_manager = aacp_manager_olm.clone();
run_async_in_thread(
async move {
aacp_manager.send_control_command(
ControlCommandIdentifiers::AllowOffOption,
if is_enabled { &[0x01] } else { &[0x02] }
).await.expect("Failed to send Off Listening Mode command");
}
);
let mut state = state.clone();
state.allow_off_mode = is_enabled;
Message::StateChanged(mac.to_string(), DeviceState::AirPods(state))
})
.spacing(0)
.size(20)
]
.align_y(Center)
)
.padding(Padding{
top: 5.0,
bottom: 5.0,
left: 18.0,
right: 18.0,
})
.style(
|theme: &Theme| {
let mut style = container::Style::default();
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.5);
style.border = border.rounded(16);
style
}
)
};
let mut information_col = column![];
let mac = mac.to_string();
if let Some(device) = devices_list.get(mac.as_str()) {
if let Some(device) = devices_list.get(mac_information.as_str()) {
if let Some(DeviceInformation::AirPods(ref airpods_info)) = device.information {
let info_rows = column![
row![
@@ -378,7 +541,7 @@ pub fn airpods_view<'a>(
)
];
} else {
error!("Expected AirPodsInformation for device {}, got something else", mac);
error!("Expected AirPodsInformation for device {}, got something else", mac.clone());
}
}
@@ -386,8 +549,12 @@ pub fn airpods_view<'a>(
column![
rename_input,
Space::with_height(Length::from(20)),
listening_mode,
Space::with_height(Length::from(20)),
audio_settings_col,
Space::with_height(Length::from(10)),
Space::with_height(Length::from(20)),
off_listening_mode_toggle,
Space::with_height(Length::from(20)),
information_col
]
)

View File

@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use iced::{Background, Border, Length, Theme};
use iced::widget::{container, text, column, row, Space, combo_box};
use iced::widget::{container, text, column, row, Space};
use crate::bluetooth::att::ATTManager;
use crate::devices::enums::{DeviceData, DeviceInformation, NothingState};
use crate::ui::window::Message;

View File

@@ -12,7 +12,7 @@ use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::{Mutex, RwLock};
use crate::bluetooth::aacp::{AACPEvent, ControlCommandIdentifiers};
use crate::bluetooth::managers::DeviceManagers;
use crate::devices::enums::{AirPodsState, DeviceData, DeviceState, DeviceType, NothingAncMode, NothingState};
use crate::devices::enums::{AirPodsNoiseControlMode, AirPodsState, DeviceData, DeviceState, DeviceType, NothingAncMode, NothingState};
use crate::ui::messages::BluetoothUIMessage;
use crate::utils::{get_devices_path, get_app_settings_path, MyTheme};
use crate::ui::airpods::airpods_view;
@@ -301,6 +301,29 @@ impl App {
};
self.device_states.insert(mac.clone(), DeviceState::AirPods(AirPodsState {
device_name,
noise_control_mode: state.control_command_status_list.iter().find_map(|status| {
if status.identifier == ControlCommandIdentifiers::ListeningMode {
status.value.get(0).map(|b| AirPodsNoiseControlMode::from_byte(b))
} else {
None
}
}).unwrap_or(AirPodsNoiseControlMode::Transparency),
noise_control_state: combo_box::State::new(
{
let mut modes = vec![
AirPodsNoiseControlMode::Transparency,
AirPodsNoiseControlMode::NoiseCancellation,
AirPodsNoiseControlMode::Adaptive
];
if state.control_command_status_list.iter().any(|status| {
status.identifier == ControlCommandIdentifiers::AllowOffOption &&
matches!(status.value.as_slice(), [0x01])
}) {
modes.insert(0, AirPodsNoiseControlMode::Off);
}
modes
}
),
conversation_awareness_enabled: state.control_command_status_list.iter().any(|status| {
status.identifier == ControlCommandIdentifiers::ConversationDetectConfig &&
matches!(status.value.as_slice(), [0x01])
@@ -309,6 +332,10 @@ impl App {
status.identifier == ControlCommandIdentifiers::AdaptiveVolumeConfig &&
matches!(status.value.as_slice(), [0x01])
}),
allow_off_mode: state.control_command_status_list.iter().any(|status| {
status.identifier == ControlCommandIdentifiers::AllowOffOption &&
matches!(status.value.as_slice(), [0x01])
}),
}));
}
Some(DeviceType::Nothing) => {
@@ -346,6 +373,12 @@ impl App {
match event {
AACPEvent::ControlCommand(status) => {
match status.identifier {
ControlCommandIdentifiers::ListeningMode => {
let mode = status.value.get(0).map(|b| AirPodsNoiseControlMode::from_byte(b)).unwrap_or(AirPodsNoiseControlMode::Transparency);
if let Some(DeviceState::AirPods(state)) = self.device_states.get_mut(&mac) {
state.noise_control_mode = mode;
}
}
ControlCommandIdentifiers::ConversationDetectConfig => {
let is_enabled = match status.value.as_slice() {
[0x01] => true,
@@ -372,6 +405,32 @@ impl App {
state.personalized_volume_enabled = is_enabled;
}
}
ControlCommandIdentifiers::AllowOffOption => {
let is_enabled = match status.value.as_slice() {
[0x01] => true,
[0x02] => false,
_ => {
error!("Unknown Allow Off Option value: {:?}", status.value);
false
}
};
if let Some(DeviceState::AirPods(state)) = self.device_states.get_mut(&mac) {
state.allow_off_mode = is_enabled;
state.noise_control_state = combo_box::State::new(
{
let mut modes = vec![
AirPodsNoiseControlMode::Transparency,
AirPodsNoiseControlMode::NoiseCancellation,
AirPodsNoiseControlMode::Adaptive
];
if is_enabled {
modes.insert(0, AirPodsNoiseControlMode::Off);
}
modes
}
);
}
}
_ => {
debug!("Unhandled Control Command Status: {:?}", status);
}
@@ -449,7 +508,39 @@ impl App {
Task::none()
}
Message::StateChanged(mac, state) => {
self.device_states.insert(mac, state);
self.device_states.insert(mac.clone(), state);
// if airpods, update the noise control state combo box based on allow off mode
let type_ = {
let devices_json = std::fs::read_to_string(get_devices_path()).unwrap_or_else(|e| {
error!("Failed to read devices file: {}", e);
"{}".to_string()
});
let devices_list: HashMap<String, DeviceData> = serde_json::from_str(&devices_json).unwrap_or_else(|e| {
error!("Deserialization failed: {}", e);
HashMap::new()
});
devices_list.get(&mac).map(|d| d.type_.clone())
};
match type_ {
Some(DeviceType::AirPods) => {
if let Some(DeviceState::AirPods(state)) = self.device_states.get_mut(&mac) {
state.noise_control_state = combo_box::State::new(
{
let mut modes = vec![
AirPodsNoiseControlMode::Transparency,
AirPodsNoiseControlMode::NoiseCancellation,
AirPodsNoiseControlMode::Adaptive
];
if state.allow_off_mode {
modes.insert(0, AirPodsNoiseControlMode::Off);
}
modes
}
);
}
}
_ => {}
}
Task::none()
}
}
@@ -622,33 +713,34 @@ impl App {
debug!("Rendering device view for {}: type={:?}, state={:?}", id, device_type, device_state);
match device_type {
Some(DeviceType::AirPods) => {
if let Some(DeviceState::AirPods(state)) = device_state {
if let Some(device_managers) = device_managers.get(id) {
if let Some(aacp_manager) = device_managers.get_aacp() {
airpods_view(id, &devices_list, state, aacp_manager.clone())
} else {
error!("No AACP manager found for AirPods device {}", id);
container(
text("No valid AACP manager found for this AirPods device").size(16)
)
.center_x(Length::Fill)
.center_y(Length::Fill)
let view = device_state.as_ref().and_then(|state| {
match state {
DeviceState::AirPods(state) => {
device_managers.get(id).and_then(|managers| {
managers.get_aacp().and_then(|aacp_manager| {
// managers.get_att().map(|att_manager| {
Some(airpods_view(
id,
&devices_list,
state,
aacp_manager.clone()
),
// att_manager.clone(),
)
// })
})
})
}
} else {
error!("No manager found for AirPods device {}", id);
container(
text("No manager found for this AirPods device").size(16)
)
.center_x(Length::Fill)
.center_y(Length::Fill)
_ => None,
}
} else {
}).unwrap_or_else(|| {
container(
text("No state available for this AirPods device").size(16)
text("Required managers or state not available for this AirPods device").size(16)
)
.center_x(Length::Fill)
.center_y(Length::Fill)
}
});
view
}
Some(DeviceType::Nothing) => {
if let Some(DeviceState::Nothing(state)) = device_state {
@@ -725,7 +817,7 @@ impl App {
border: Border {
width: 1.0,
color: theme.palette().text,
radius: Radius::from(8.0)
radius: Radius::from(4.0)
},
text_color: theme.palette().text,
selected_text_color: theme.palette().text,