Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions linux-rust/src/bluetooth/le.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::bluetooth::aacp::BatteryStatus;
use crate::bluetooth::aacp::{AACPEvent, BatteryComponent, BatteryInfo, BatteryStatus};
use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType};
use crate::ui::messages::BluetoothUIMessage;
use crate::ui::tray::MyTray;
use crate::utils::{ah, get_devices_path, get_preferences_path};
use aes::Aes128;
Expand All @@ -15,6 +16,7 @@ use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::mpsc::UnboundedSender;

fn decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(&Array::from(*key));
Expand Down Expand Up @@ -46,7 +48,10 @@ fn verify_rpa(addr: &str, irk: &[u8; 16]) -> bool {
hash == computed_hash
}

pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> bluer::Result<()> {
pub async fn start_le_monitor(
tray_handle: Option<ksni::Handle<MyTray>>,
ui_tx: UnboundedSender<BluetoothUIMessage>,
) -> bluer::Result<()> {
let session = Session::new().await?;
let adapter = session.default_adapter().await?;
adapter.set_powered(true).await?;
Expand Down Expand Up @@ -143,6 +148,7 @@ pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> blue
if matched_airpods_mac.is_some() {
let mut events = dev.events().await?;
let tray_handle_clone = tray_handle.clone();
let ui_tx_clone = ui_tx.clone();
let connecting_macs_clone = Arc::clone(&connecting_macs);
tokio::spawn(async move {
while let Some(ev) = events.next().await {
Expand Down Expand Up @@ -246,6 +252,7 @@ pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> blue
);
}
}
} else {
info!(
"Auto-connect is disabled for {}, not attempting to connect.",
matched_airpods_mac.as_ref().unwrap()
Expand Down Expand Up @@ -336,6 +343,36 @@ pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> blue
.await;
}

// The tray is not the only consumer: the window shows the
// case level too, and over AACP the case reports itself as
// disconnected whenever the buds are outside it.
if let Some(mac) = matched_airpods_mac.as_ref() {
let battery_info = [
(BatteryComponent::Left, left_byte, left_battery, left_charging),
(BatteryComponent::Right, right_byte, right_battery, right_charging),
(BatteryComponent::Case, case_byte, case_battery, case_charging),
]
.into_iter()
.filter(|(_, raw, _, _)| *raw != 0xff)
.map(|(component, _, level, charging)| BatteryInfo {
component,
level: level as u8,
status: if charging {
BatteryStatus::Charging
} else {
BatteryStatus::NotCharging
},
})
.collect::<Vec<_>>();

if !battery_info.is_empty() {
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(
mac.clone(),
AACPEvent::BatteryInfo(battery_info),
));
}
}

debug!(
"Battery status: Left: {}, Right: {}, Case: {}, InEar: L:{} R:{}",
if left_byte == 0xff {
Expand Down
9 changes: 9 additions & 0 deletions linux-rust/src/devices/airpods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ impl AirPodsDevice {
}
});

// PipeWire can leave the card on the "off" profile when it appears
// before its A2DP transport is ready, and nothing revisits that choice
// until playback starts - so freshly connected buds stay silent, and
// the microphone has no transport either. Claim a profile right away.
let mc_profile = media_controller.clone();
tokio::spawn(async move {
mc_profile.lock().await.activate_a2dp_profile().await;
});

let mc_listener = media_controller.lock().await;
let aacp_manager_clone_listener = aacp_manager.clone();
mc_listener
Expand Down
35 changes: 28 additions & 7 deletions linux-rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,20 @@ fn main() -> iced::Result {
// Run headless without UI
info!("Running in headless mode (no GUI)");
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async_main(ui_tx, device_managers)).unwrap();
if let Err(e) = rt.block_on(async_main(ui_tx, device_managers)) {
log::error!("LibrePods could not start: {e}");
std::process::exit(1);
}
Ok(())
} else {
// Run with UI
let device_managers_clone = device_managers.clone();
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async_main(ui_tx, device_managers_clone))
.unwrap();
if let Err(e) = rt.block_on(async_main(ui_tx, device_managers_clone)) {
log::error!("LibrePods could not start: {e}");
std::process::exit(1);
}
});

ui::window::start_ui(ui_rx, args.start_minimized, device_managers)
Expand Down Expand Up @@ -149,14 +154,30 @@ async fn async_main(
Some(handle)
};

let session = bluer::Session::new().await?;
let adapter = session.default_adapter().await?;
adapter.set_powered(true).await?;
let session = bluer::Session::new().await.inspect_err(|e| {
log::error!(
"Cannot talk to BlueZ over D-Bus: {e}. Is the bluetooth service running? \
Check with `systemctl status bluetooth`."
)
})?;
let adapter = session.default_adapter().await.inspect_err(|e| {
log::error!(
"No Bluetooth adapter available: {e}. Make sure an adapter is present \
and not blocked - see `rfkill list bluetooth`."
)
})?;
adapter.set_powered(true).await.inspect_err(|e| {
log::error!(
"Cannot power on the Bluetooth adapter: {e}. It is likely soft-blocked, \
try `rfkill unblock bluetooth`."
)
})?;

let le_tray_clone = tray_handle.clone();
let le_ui_tx = ui_tx.clone();
tokio::spawn(async move {
info!("Starting LE monitor...");
if let Err(e) = start_le_monitor(le_tray_clone).await {
if let Err(e) = start_le_monitor(le_tray_clone, le_ui_tx).await {
log::error!("LE monitor error: {}", e);
}
});
Expand Down
179 changes: 112 additions & 67 deletions linux-rust/src/media_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ struct OwnedCardInfo {
index: u32,
proplist: Proplist,
profiles: Vec<OwnedCardProfileInfo>,
active_profile: Option<String>,
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -301,75 +302,133 @@ impl MediaController {
}
}

/// Number of one-second attempts to wait for A2DP profiles to be enumerated.
const A2DP_ENUMERATION_ATTEMPTS: u32 = 5;

/// Currently active profile of the card, used to avoid pointless switches.
async fn get_active_profile(&self, card_index: u32) -> Option<String> {
tokio::task::spawn_blocking(move || {
get_card_info_list_sync()
.iter()
.find(|c| c.index == card_index)
.and_then(|c| c.active_profile.clone())
})
.await
.unwrap_or(None)
}

/// Resolves the card by Bluetooth MAC and refreshes the cached index.
/// PipeWire can assign a different index after a disconnect/reconnect.
async fn refresh_device_index(&self) -> Option<u32> {
let mac = self.state.lock().await.connected_device_mac.clone();
if mac.is_empty() {
return None;
}
let index = self.get_audio_device_index(&mac).await;
self.state.lock().await.device_index = index;
index
}

/// Waits for the card to expose an A2DP profile, re-resolving the card on
/// every attempt. Sleeps between attempts, but never after the last one.
async fn wait_for_a2dp_profile(&self, attempts: u32) -> bool {
for attempt in 0..attempts {
if self.refresh_device_index().await.is_some() && self.is_a2dp_profile_available().await
{
return true;
}
if attempt + 1 < attempts {
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
false
}

async fn restart_wire_plumber(&self) -> bool {
info!("Restarting WirePlumber to rediscover A2DP profiles");
let result = Command::new("systemctl")
.args(["--user", "restart", "wireplumber"])
.output();

match result {
Ok(output) if output.status.success() => {
info!("WirePlumber restarted successfully");
tokio::time::sleep(Duration::from_secs(2)).await;
true
}
_ => {
error!("Failed to restart WirePlumber. Do you use wireplumber?");
false
}
}
}

pub async fn activate_a2dp_profile(&self) {
debug!("Entering activate_a2dp_profile");
let state = self.state.lock().await;

if state.connected_device_mac.is_empty() {
if self.state.lock().await.connected_device_mac.is_empty() {
warn!("Connected device MAC is empty, cannot activate A2DP profile");
return;
}

let device_index = state.device_index;
let mac = state.connected_device_mac.clone();
drop(state);

let mut current_device_index = device_index;

if current_device_index.is_none() {
warn!("Device index not found, trying to get it.");
current_device_index = self.get_audio_device_index(&mac).await;
if let Some(idx) = current_device_index {
self.state.lock().await.device_index = Some(idx);
} else {
warn!("Could not get device index. Cannot activate A2DP profile.");
return;
}
// Always resolve the card by MAC first: a reconnect can change its index.
if self.refresh_device_index().await.is_none() {
warn!("Could not get device index. Cannot activate A2DP profile.");
return;
}

if !self.is_a2dp_profile_available().await {
warn!("A2DP profile not available, attempting to restart WirePlumber");
if self.restart_wire_plumber().await {
let mut state = self.state.lock().await;
state.device_index = self
.get_audio_device_index(&state.connected_device_mac)
.await;
debug!(
"Updated device_index after WirePlumber restart: {:?}",
state.device_index
);
if !self.is_a2dp_profile_available().await {
error!("A2DP profile still not available after WirePlumber restart");
// A freshly connected card can show up before its profiles are
// enumerated. Give that a grace period before restarting
// WirePlumber, which interrupts playback for several seconds.
warn!("A2DP profile not available yet, waiting for enumeration");
if !self
.wait_for_a2dp_profile(Self::A2DP_ENUMERATION_ATTEMPTS)
.await
{
warn!("A2DP profile still missing, restarting WirePlumber as a last resort");
if !self.restart_wire_plumber().await
|| !self
.wait_for_a2dp_profile(Self::A2DP_ENUMERATION_ATTEMPTS)
.await
{
error!("A2DP profile unavailable, skipping profile activation");
return;
}
} else {
error!("Could not restart WirePlumber, A2DP profile unavailable");
return;
}
}

let Some(idx) = self.state.lock().await.device_index else {
error!("Device index not available for activating profile.");
return;
};

// Leave an already-active A2DP variant alone. Switching profiles
// recreates the PipeWire sink and stops the stream that triggered this
// activation, so players pause again right after the user pressed play.
if let Some(active) = self.get_active_profile(idx).await
&& active.starts_with("a2dp")
{
debug!("A2DP profile {} already active, leaving it unchanged", active);
return;
}

let preferred_profile = self.get_preferred_a2dp_profile().await;
if preferred_profile.is_empty() {
error!("No suitable A2DP profile found");
return;
}

let device_index = self.state.lock().await.device_index;
if let Some(idx) = device_index {
info!("Activating A2DP profile for AirPods: {}", preferred_profile);
let profile_name = preferred_profile.clone();
let success =
tokio::task::spawn_blocking(move || set_card_profile_sync(idx, &profile_name))
.await
.unwrap_or(false);

if success {
info!("Successfully activated A2DP profile: {}", preferred_profile);
} else {
warn!("Failed to activate A2DP profile: {}", preferred_profile);
}
info!("Activating A2DP profile for AirPods: {}", preferred_profile);
let profile_name = preferred_profile.clone();
let success = tokio::task::spawn_blocking(move || set_card_profile_sync(idx, &profile_name))
.await
.unwrap_or(false);

if success {
info!("Successfully activated A2DP profile: {}", preferred_profile);
} else {
error!("Device index not available for activating profile.");
warn!("Failed to activate A2DP profile: {}", preferred_profile);
}
}

Expand Down Expand Up @@ -564,7 +623,7 @@ impl MediaController {
return cached_profile;
}

for profile in ["a2dp-sink-sbc_xq", "a2dp-sink-sbc", "a2dp-sink"] {
for profile in crate::utils::load_preferred_codec().profile_order() {
if self.is_profile_available(index, profile).await {
info!("Selected best available A2DP profile: {}", profile);
self.state.lock().await.cached_a2dp_profile = profile.to_string();
Expand Down Expand Up @@ -594,25 +653,6 @@ impl MediaController {
.unwrap_or(false)
}

async fn restart_wire_plumber(&self) -> bool {
info!("Restarting WirePlumber to rediscover A2DP profiles");
let result = Command::new("systemctl")
.args(["--user", "restart", "wireplumber"])
.output();

match result {
Ok(output) if output.status.success() => {
info!("WirePlumber restarted successfully");
tokio::time::sleep(Duration::from_secs(2)).await;
true
}
_ => {
error!("Failed to restart WirePlumber. Do you use wireplumber?");
false
}
}
}

async fn get_audio_device_index(&self, mac: &str) -> Option<u32> {
if mac.is_empty() {
return None;
Expand Down Expand Up @@ -920,10 +960,15 @@ fn get_card_info_list_sync() -> Vec<OwnedCardInfo> {
name: p.name.as_ref().map(|n| n.to_string()),
})
.collect();
let active_profile = item
.active_profile
.as_ref()
.and_then(|p| p.name.as_ref().map(|n| n.to_string()));
cards.borrow_mut().push(OwnedCardInfo {
index: item.index,
proplist: item.proplist.clone(),
profiles,
active_profile,
});
}
}
Expand Down
Loading