From 51b3d4692aaf4b77a854f6edffc9a523234e3518 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Tue, 28 Oct 2025 12:15:52 +0530 Subject: [PATCH] linux-rust: fix conv-detect and add le auto-connect --- linux-rust/src/airpods.rs | 10 +- linux-rust/src/bluetooth/aacp.rs | 64 ++++++++----- linux-rust/src/bluetooth/le.rs | 149 +++++++++++++++++++++++------ linux-rust/src/bluetooth/mod.rs | 2 +- linux-rust/src/main.rs | 2 +- linux-rust/src/media_controller.rs | 97 ++++++++++++++----- linux-rust/src/ui/tray.rs | 5 +- 7 files changed, 244 insertions(+), 85 deletions(-) diff --git a/linux-rust/src/airpods.rs b/linux-rust/src/airpods.rs index 1566848..67be313 100644 --- a/linux-rust/src/airpods.rs +++ b/linux-rust/src/airpods.rs @@ -1,6 +1,6 @@ use crate::bluetooth::aacp::{AACPManager, ProximityKeyType, AACPEvent}; 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}; @@ -13,7 +13,7 @@ use crate::ui::tray::MyTray; pub struct AirPodsDevice { pub mac_address: Address, pub aacp_manager: AACPManager, - // pub att_manager: ATTManager, + pub att_manager: ATTManager, pub media_controller: Arc>, } @@ -23,8 +23,8 @@ impl AirPodsDevice { let mut aacp_manager = AACPManager::new(); aacp_manager.connect(mac_address).await; - // let mut att_manager = ATTManager::new(); - // att_manager.connect(mac_address).await.expect("Failed to connect ATT"); + let mut att_manager = ATTManager::new(); + att_manager.connect(mac_address).await.expect("Failed to connect ATT"); if let Some(handle) = &tray_handle { handle.update(|tray: &mut MyTray| tray.connected = true).await; @@ -226,7 +226,7 @@ impl AirPodsDevice { AirPodsDevice { mac_address, aacp_manager, - // att_manager, + att_manager, media_controller, } } diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index 6141be6..4324aa8 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -242,20 +242,21 @@ pub enum AACPEvent { OwnershipToFalseRequest, } -struct AACPManagerState { - sender: Option>>, - control_command_status_list: Vec, - control_command_subscribers: HashMap>>>, - owns: bool, - old_connected_devices: Vec, - connected_devices: Vec, - audio_source: Option, - battery_info: Vec, +pub struct AACPManagerState { + pub sender: Option>>, + pub control_command_status_list: Vec, + pub control_command_subscribers: HashMap>>>, + pub owns: bool, + pub old_connected_devices: Vec, + pub connected_devices: Vec, + pub audio_source: Option, + pub battery_info: Vec, pub conversational_awareness_status: u8, - old_ear_detection_status: Vec, - ear_detection_status: Vec, + pub old_ear_detection_status: Vec, + pub ear_detection_status: Vec, event_tx: Option>, proximity_keys: HashMap>, + pub airpods_mac: Option
, } impl AACPManagerState { @@ -278,6 +279,7 @@ impl AACPManagerState { ear_detection_status: Vec::new(), event_tx: None, proximity_keys, + airpods_mac: None, } } } @@ -300,6 +302,11 @@ impl AACPManager { info!("AACPManager connecting to {} on PSM {:#06X}...", addr, PSM); let target_sa = SocketAddr::new(addr, AddressType::BrEdr, PSM); + { + let mut state = self.state.lock().await; + state.airpods_mac = Some(addr); + } + let socket = match Socket::new_seq_packet() { Ok(s) => s, Err(e) => { @@ -384,11 +391,7 @@ impl AACPManager { let mut state = self.state.lock().await; state.event_tx = Some(tx); } - - pub async fn get_connected_devices(&self) -> Vec { - self.state.lock().await.connected_devices.clone() - } - + pub async fn subscribe_to_control_command(&self, identifier: ControlCommandIdentifiers, tx: mpsc::UnboundedSender>) { let mut state = self.state.lock().await; state.control_command_subscribers.entry(identifier).or_default().push(tx); @@ -573,17 +576,28 @@ impl AACPManager { } } - let json = serde_json::to_string(&state.proximity_keys).unwrap(); - let path = get_proximity_keys_path(); - if let Some(parent) = path.parent() { - if let Err(e) = tokio::fs::create_dir_all(&parent).await { - error!("Failed to create directory for proximity keys: {}", e); - return; + if let Some(mac) = state.airpods_mac { + let path = get_proximity_keys_path(); + let mut all_keys: HashMap>> = + std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + all_keys.insert(mac.to_string(), state.proximity_keys.clone()); + + let json = serde_json::to_string(&all_keys).unwrap(); + if let Some(parent) = path.parent() { + if let Err(e) = tokio::fs::create_dir_all(&parent).await { + error!("Failed to create directory for proximity keys: {}", e); + return; + } + } + if let Err(e) = tokio::fs::write(&path, json).await { + error!("Failed to save proximity keys: {}", e); } } - if let Err(e) = tokio::fs::write(&path, json).await { - error!("Failed to save proximity keys: {}", e); - } + if let Some(ref tx) = state.event_tx { let _ = tx.send(AACPEvent::ProximityKeys(keys)); } diff --git a/linux-rust/src/bluetooth/le.rs b/linux-rust/src/bluetooth/le.rs index 98a8c5f..1674ac8 100644 --- a/linux-rust/src/bluetooth/le.rs +++ b/linux-rust/src/bluetooth/le.rs @@ -1,16 +1,18 @@ -use bluer::monitor::{Monitor, MonitorEvent, Pattern, RssiSamplingPeriod}; +use bluer::monitor::{Monitor, MonitorEvent, Pattern}; use bluer::{Address, Session}; use aes::Aes128; use aes::cipher::{BlockEncrypt, KeyInit, BlockDecrypt}; use aes::cipher::generic_array::GenericArray; use std::collections::{HashMap, HashSet}; -use log::{info, error, debug}; +use log::{info, debug}; use serde_json; use crate::bluetooth::aacp::ProximityKeyType; use futures::StreamExt; use hex; -use std::time::Duration; use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; +use tokio::sync::Mutex; use crate::bluetooth::aacp::BatteryStatus; use crate::ui::tray::MyTray; @@ -20,6 +22,12 @@ fn get_proximity_keys_path() -> PathBuf { PathBuf::from(data_dir).join("librepods").join("proximity_keys.json") } +fn get_preferences_path() -> PathBuf { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .unwrap_or_else(|_| format!("{}/.config", std::env::var("HOME").unwrap_or_default())); + PathBuf::from(config_dir).join("librepods").join("preferences.json") +} + fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let mut swapped_key = *key; swapped_key.reverse(); @@ -73,15 +81,15 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue let adapter = session.default_adapter().await?; adapter.set_powered(true).await?; - let proximity_keys: HashMap> = std::fs::read_to_string(get_proximity_keys_path()) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - let irk = proximity_keys.get(&ProximityKeyType::Irk) - .and_then(|v| if v.len() == 16 { Some(<[u8; 16]>::try_from(v.as_slice()).unwrap()) } else { None }); - let enc_key = proximity_keys.get(&ProximityKeyType::EncKey) - .and_then(|v| if v.len() == 16 { Some(<[u8; 16]>::try_from(v.as_slice()).unwrap()) } else { None }); - let mut verified_macs: HashSet
= HashSet::new(); + let all_proximity_keys: HashMap>> = + std::fs::read_to_string(get_proximity_keys_path()) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + let mut verified_macs: HashMap = HashMap::new(); + let mut failed_macs: HashSet
= HashSet::new(); + let connecting_macs = Arc::new(Mutex::new(HashSet::
::new())); let pattern = Pattern { data_type: 0xFF, // Manufacturer specific data @@ -107,41 +115,128 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue while let Some(mevt) = monitor_handle.next().await { if let MonitorEvent::DeviceFound(devid) = mevt { - let dev = adapter.device(devid.device)?; + let adapter_monitor_clone = adapter.clone(); + let dev = adapter_monitor_clone.device(devid.device)?; let addr = dev.address(); let addr_str = addr.to_string(); - debug!("Found device: {}", addr_str); + let matched_airpods_mac: Option; + let mut matched_enc_key: Option<[u8; 16]> = None; - if !verified_macs.contains(&addr) { + if let Some(airpods_mac) = verified_macs.get(&addr) { + matched_airpods_mac = Some(airpods_mac.clone()); + } else if failed_macs.contains(&addr) { + continue; + } else { debug!("Checking RPA for device: {}", addr_str); - if let Some(irk) = &irk { - if verify_rpa(&addr_str, irk) { - verified_macs.insert(addr); - info!("Matched our device ({}) with the irk", addr); - } else { - debug!("Device {} did not match our irk", addr); + let mut found_mac = None; + for (airpods_mac, keys) in &all_proximity_keys { + if let Some(irk_vec) = keys.get(&ProximityKeyType::Irk) { + if irk_vec.len() == 16 { + let irk: [u8; 16] = irk_vec.as_slice().try_into().unwrap(); + debug!("Verifying RPA {} for airpods MAC {} with IRK {}", addr_str, airpods_mac, hex::encode(irk)); + if verify_rpa(&addr_str, &irk) { + info!("Matched our device ({}) with the irk for {}", addr, airpods_mac); + verified_macs.insert(addr, airpods_mac.clone()); + found_mac = Some(airpods_mac.clone()); + break; + } + } + } + } + + if let Some(mac) = found_mac { + matched_airpods_mac = Some(mac); + } else { + failed_macs.insert(addr); + debug!("Device {} did not match any of our irks", addr); + continue; + } + } + + if let Some(ref mac) = matched_airpods_mac { + if let Some(keys) = all_proximity_keys.get(mac) { + if let Some(enc_key_vec) = keys.get(&ProximityKeyType::EncKey) { + if enc_key_vec.len() == 16 { + matched_enc_key = Some(enc_key_vec.as_slice().try_into().unwrap()); + } } } } - if verified_macs.contains(&addr) { + if matched_airpods_mac.is_some() { let mut events = dev.events().await?; let tray_handle_clone = tray_handle.clone(); + let connecting_macs_clone = Arc::clone(&connecting_macs); tokio::spawn(async move { while let Some(ev) = events.next().await { match ev { bluer::DeviceEvent::PropertyChanged(prop) => { match prop { bluer::DeviceProperty::ManufacturerData(data) => { - debug!("Manufacturer data from {}: {:?}", addr_str, data.iter().map(|(k, v)| (k, hex::encode(v))).collect::>()); - if let Some(enc_key) = &enc_key { + if let Some(enc_key) = &matched_enc_key { if let Some(apple_data) = data.get(&76) { if apple_data.len() > 20 { let last_16: [u8; 16] = apple_data[apple_data.len() - 16..].try_into().unwrap(); let decrypted = decrypt(enc_key, &last_16); - debug!("Decrypted data from {}: {}", addr_str, hex::encode(decrypted)); - + debug!("Decrypted data from airpods_mac {}: {}", + matched_airpods_mac.as_ref().unwrap_or(&"unknown".to_string()), + hex::encode(&decrypted)); + + let connection_state = apple_data[10] as usize; + debug!("Connection state: {}", connection_state); + if connection_state == 0x00 { + let pref_path = get_preferences_path(); + let preferences: HashMap> = + std::fs::read_to_string(&pref_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let auto_connect = preferences.get(matched_airpods_mac.as_ref().unwrap()) + .and_then(|prefs| prefs.get("autoConnect")) + .copied() + .unwrap_or(true); + debug!("Auto-connect preference for {}: {}", matched_airpods_mac.as_ref().unwrap(), auto_connect); + if auto_connect { + let real_address = Address::from_str(&addr_str).unwrap(); + let mut cm = connecting_macs_clone.lock().await; + if cm.contains(&real_address) { + info!("Already connecting to {}, skipping duplicate attempt.", matched_airpods_mac.as_ref().unwrap()); + return; + } + cm.insert(real_address); + // let adapter_clone = adapter_monitor_clone.clone(); + // let real_device = adapter_clone.device(real_address).unwrap(); + info!("AirPods are disconnected, attempting to connect to {}", matched_airpods_mac.as_ref().unwrap()); + // if let Err(e) = real_device.connect().await { + // info!("Failed to connect to AirPods {}: {}", matched_airpods_mac.as_ref().unwrap(), e); + // } else { + // info!("Successfully connected to AirPods {}", matched_airpods_mac.as_ref().unwrap()); + // } + // call bluetoothctl connect for now, I don't know why bluer connect isn't working + let output = tokio::process::Command::new("bluetoothctl") + .arg("connect") + .arg(matched_airpods_mac.as_ref().unwrap()) + .output() + .await; + match output { + Ok(output) => { + if output.status.success() { + info!("Successfully connected to AirPods {}", matched_airpods_mac.as_ref().unwrap()); + cm.remove(&real_address); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + info!("Failed to connect to AirPods {}: {}", matched_airpods_mac.as_ref().unwrap(), stderr); + } + } + Err(e) => { + info!("Failed to execute bluetoothctl to connect to AirPods {}: {}", matched_airpods_mac.as_ref().unwrap(), e); + } + } + info!("Auto-connect is disabled for {}, not attempting to connect.", matched_airpods_mac.as_ref().unwrap()); + } + } + let status = apple_data[5] as usize; let primary_left = (status >> 5) & 0x01 == 1; let this_in_case = (status >> 6) & 0x01 == 1; @@ -184,7 +279,7 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue }).await; } - info!("Battery status: Left: {}, Right: {}, Case: {}, InEar: L:{} R:{}", + debug!("Battery status: Left: {}, Right: {}, Case: {}, InEar: L:{} R:{}", if left_byte == 0xff { "disconnected".to_string() } else { format!("{}% (charging: {})", left_battery, left_charging) }, if right_byte == 0xff { "disconnected".to_string() } else { format!("{}% (charging: {})", right_battery, right_charging) }, if case_byte == 0xff { "disconnected".to_string() } else { format!("{}% (charging: {})", case_battery, case_charging) }, diff --git a/linux-rust/src/bluetooth/mod.rs b/linux-rust/src/bluetooth/mod.rs index b9f7dab..d628d9e 100644 --- a/linux-rust/src/bluetooth/mod.rs +++ b/linux-rust/src/bluetooth/mod.rs @@ -1,4 +1,4 @@ pub(crate) mod discovery; pub mod aacp; -// pub mod att; +pub mod att; pub mod le; \ No newline at end of file diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index 1c4239c..7d35e12 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -4,7 +4,7 @@ mod media_controller; mod ui; use std::env; -use log::{debug, info}; +use log::info; use dbus::blocking::Connection; use dbus::blocking::stdintf::org_freedesktop_dbus::Properties; use dbus::message::MatchRule; diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs index 8d9b844..669195e 100644 --- a/linux-rust/src/media_controller.rs +++ b/linux-rust/src/media_controller.rs @@ -124,13 +124,18 @@ impl MediaController { drop(state); if !was_playing && is_playing { + let aacp_state = aacp_manager.state.lock().await; + if !aacp_state.ear_detection_status.contains(&EarDetectionStatus::InEar) { + info!("Media playback started but buds not in ear, skipping takeover"); + continue; + } info!("Media playback started, taking ownership and activating a2dp"); let _ = control_tx.send((crate::bluetooth::aacp::ControlCommandIdentifiers::OwnsConnection, vec![0x01])); self.activate_a2dp_profile().await; info!("already connected locally, hijacking connection by asking AirPods"); - let connected_devices = aacp_manager.get_connected_devices().await; + let connected_devices = aacp_state.connected_devices.clone(); for device in connected_devices { if device.mac != local_mac { if let Err(e) = aacp_manager.send_media_information(&local_mac, &device.mac, true).await { @@ -144,6 +149,8 @@ impl MediaController { } } } + + debug!("completed playback takeover process"); } } } @@ -718,6 +725,7 @@ impl MediaController { if let Some(list) = card_info_list.borrow().as_ref() { for card in list { + debug!("Checking card index {} for MAC match", card.index); let props = &card.proplist; if let Some(device_string) = props.get_str("device.string") { if device_string.contains(&mac_clone) { @@ -905,6 +913,29 @@ impl MediaController { debug!("No stored original volume to restore to on status 6"); } } + 9 => { + let mut maybe_original = None; + { + let mut state = self.state.lock().await; + if state.conv_conversation_started { + maybe_original = state.conv_original_volume; + state.conv_original_volume = None; + state.conv_conversation_started = false; + } else { + debug!("Received status 9 but conversation was not started; ignoring restore"); + return; + } + } + if let Some(orig) = maybe_original { + let sink_clone = sink.clone(); + tokio::task::spawn_blocking(move || { + transition_sink_volume(&sink_clone, orig) + }).await.unwrap_or(false); + info!("Conversation end (9): restored volume to original {}", orig); + } else { + debug!("No stored original volume to restore to on status 9"); + } + } _ => { debug!("Unknown conversational awareness status: {}", status); } @@ -913,30 +944,48 @@ impl MediaController { } fn get_sink_volume_percent_by_name_sync(sink_name: &str) -> Option { - match Command::new("pactl").args(&["get-sink-volume", sink_name]).output() { - Ok(output) if output.status.success() => { - if let Ok(s) = String::from_utf8(output.stdout) { - if let Some(pct_idx) = s.find('%') { - let mut start = pct_idx; - let bytes = s.as_bytes(); - while start > 0 { - let b = bytes[start - 1]; - if b.is_ascii_digit() { - start -= 1; - } else { - break; - } - } - if start < pct_idx { - if let Ok(num) = s[start..pct_idx].trim().parse::() { - return Some(num); - } - } - } - } - None + let mut mainloop = Mainloop::new().unwrap(); + let mut context = Context::new(&mut mainloop, "LibrePods-get_sink_volume").unwrap(); + context.connect(None, ContextFlagSet::NOAUTOSPAWN, None).unwrap(); + loop { + match mainloop.iterate(false) { + _ if context.get_state() == libpulse_binding::context::State::Ready => break, + _ if context.get_state() == libpulse_binding::context::State::Failed || context.get_state() == libpulse_binding::context::State::Terminated => return None, + _ => {}, } - _ => None, + } + + let introspector = context.introspect(); + let sink_info_option = Rc::new(RefCell::new(None)); + let op = introspector.get_sink_info_by_name(sink_name, { + let sink_info_option = sink_info_option.clone(); + move |result: ListResult<&SinkInfo>| { + if let ListResult::Item(item) = result { + let owned_item = OwnedSinkInfo { + name: item.name.as_ref().map(|s| s.to_string()), + proplist: item.proplist.clone(), + volume: item.volume, + }; + *sink_info_option.borrow_mut() = Some(owned_item); + } + } + }); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); + + if let Some(sink_info) = sink_info_option.borrow().as_ref() { + let channels = sink_info.volume.len(); + if channels == 0 { + return None; + } + let total: f64 = sink_info.volume.get().iter().map(|v| v.0 as f64).sum(); + let average_raw = total / channels as f64; + let percent = ((average_raw / Volume::NORMAL.0 as f64) * 100.0).round() as u32; + Some(percent) + } else { + None } } diff --git a/linux-rust/src/ui/tray.rs b/linux-rust/src/ui/tray.rs index a4cc119..51ce171 100644 --- a/linux-rust/src/ui/tray.rs +++ b/linux-rust/src/ui/tray.rs @@ -90,13 +90,13 @@ impl ksni::Tray for MyTray { let options = if allow_off { vec![ ("Off", 0x01), - ("ANC", 0x02), + ("Noise Cancellation", 0x02), ("Transparency", 0x03), ("Adaptive", 0x04), ] } else { vec![ - ("ANC", 0x02), + ("Noise Cancellation", 0x02), ("Transparency", 0x03), ("Adaptive", 0x04), ] @@ -121,6 +121,7 @@ impl ksni::Tray for MyTray { ..Default::default() } .into(), + MenuItem::Separator, CheckmarkItem { label: "Conversation Detection".into(), checked: self.conversation_detect_enabled.unwrap_or(false),