use crate::error::TalkError;
use libpulse_binding as pulse;
use pulse::callbacks::ListResult;
use pulse::context::introspect::CardInfo;
use pulse::context::{Context, FlagSet as ContextFlagSet, State as ContextState};
use pulse::mainloop::standard::{IterateResult, Mainloop};
use pulse::operation::State as OperationState;
use pulse::proplist::{properties as pa_props, Proplist};
use serde::{Deserialize, Serialize};
use std::cell::RefCell;
use std::fs;
use std::path::PathBuf;
use std::rc::Rc;
const PREFERRED_HFP_PROFILES: &[&str] = &[
"headset-head-unit-msbc",
"headset-head-unit-cvsd",
"headset-head-unit",
];
const PA_APP_NAME: &str = "talk-rs";
const STATE_FILE_NAME: &str = "card-profile.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SavedProfile {
pub card_name: String,
pub original_profile: String,
pub switched_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CardSnapshot {
name: String,
form_factor: Option<String>,
active_profile: String,
profiles: Vec<String>,
}
pub fn activate_headset() -> Result<Option<SavedProfile>, TalkError> {
let cards = list_cards()?;
let Some(card) = find_headset_card(&cards) else {
log::debug!("bt_profile: no Bluetooth headset card detected");
return Ok(None);
};
let Some(hfp_profile) = pick_hfp_profile(&card.profiles) else {
log::warn!(
"bt_profile: headset card '{}' has no HFP profile available (profiles: {:?})",
card.name,
card.profiles
);
return Ok(None);
};
if card.active_profile == hfp_profile
|| PREFERRED_HFP_PROFILES.contains(&card.active_profile.as_str())
{
log::debug!(
"bt_profile: card '{}' already on HFP profile '{}', skipping switch",
card.name,
card.active_profile
);
return Ok(None);
}
let saved = SavedProfile {
card_name: card.name.clone(),
original_profile: card.active_profile.clone(),
switched_at: chrono::Utc::now(),
};
write_state_file(&saved)?;
log::info!(
"bt_profile: switching card '{}' from '{}' to '{}' for microphone capture",
card.name,
card.active_profile,
hfp_profile
);
if let Err(err) = set_card_profile(&card.name, hfp_profile) {
let _ = remove_state_file();
return Err(err);
}
Ok(Some(saved))
}
pub fn restore_profile(saved: &SavedProfile) -> Result<(), TalkError> {
log::info!(
"bt_profile: restoring card '{}' to profile '{}'",
saved.card_name,
saved.original_profile
);
let switch_result = set_card_profile(&saved.card_name, &saved.original_profile);
let _ = remove_state_file();
switch_result
}
pub fn recover_stale_profile() -> Result<bool, TalkError> {
let path = state_file_path()?;
if !path.exists() {
return Ok(false);
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(err) => {
log::warn!(
"bt_profile: failed to read stale state file {}: {}",
path.display(),
err
);
let _ = fs::remove_file(&path);
return Ok(false);
}
};
let saved: SavedProfile = match serde_json::from_str(&content) {
Ok(s) => s,
Err(err) => {
log::warn!(
"bt_profile: malformed state file {} (deleting): {}",
path.display(),
err
);
let _ = fs::remove_file(&path);
return Ok(false);
}
};
log::info!(
"bt_profile: recovering stale profile from previous run: card='{}' profile='{}' (saved at {})",
saved.card_name,
saved.original_profile,
saved.switched_at
);
match restore_profile(&saved) {
Ok(()) => Ok(true),
Err(err) => {
log::warn!("bt_profile: stale profile recovery failed: {}", err);
Ok(false)
}
}
}
pub struct HeadsetGuard {
saved: Option<SavedProfile>,
}
impl HeadsetGuard {
pub fn new(saved: Option<SavedProfile>) -> Self {
Self { saved }
}
pub fn restore_now_async(&mut self) {
let Some(saved) = self.saved.take() else {
return;
};
tokio::task::spawn_blocking(move || {
if let Err(err) = restore_profile(&saved) {
log::warn!(
"bt_profile: failed to restore profile asynchronously ({}): {}",
saved.card_name,
err
);
}
});
}
}
impl Drop for HeadsetGuard {
fn drop(&mut self) {
if let Some(saved) = self.saved.take() {
if let Err(err) = restore_profile(&saved) {
log::warn!(
"bt_profile: failed to restore profile on guard drop ({}): {}",
saved.card_name,
err
);
}
}
}
}
fn pick_hfp_profile<'a>(available: &[String]) -> Option<&'a str> {
PREFERRED_HFP_PROFILES
.iter()
.find(|preferred| available.iter().any(|a| a == *preferred))
.copied()
}
fn find_headset_card(cards: &[CardSnapshot]) -> Option<&CardSnapshot> {
cards.iter().find(|c| is_headset_card(c))
}
fn is_headset_card(card: &CardSnapshot) -> bool {
if card.form_factor.as_deref() == Some("headset") {
return true;
}
if card.name.starts_with("bluez_card.")
&& card
.profiles
.iter()
.any(|p| p.starts_with("headset-head-unit"))
{
return true;
}
false
}
fn state_file_path() -> Result<PathBuf, TalkError> {
let dir = if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(rt).join("talk-rs")
} else if let Ok(user) = std::env::var("USER") {
PathBuf::from(format!("/tmp/talk-rs-{}", user))
} else {
PathBuf::from("/tmp/talk-rs")
};
Ok(dir.join(STATE_FILE_NAME))
}
fn write_state_file(saved: &SavedProfile) -> Result<(), TalkError> {
let path = state_file_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(TalkError::Io)?;
}
let json = serde_json::to_string_pretty(saved).map_err(|err| {
TalkError::Audio(format!(
"bt_profile: failed to serialize saved profile: {}",
err
))
})?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json).map_err(TalkError::Io)?;
fs::rename(&tmp, &path).map_err(TalkError::Io)?;
Ok(())
}
fn remove_state_file() -> Result<(), TalkError> {
let path = state_file_path()?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(TalkError::Io(err)),
}
}
fn with_pulse_context<F, R>(f: F) -> Result<R, TalkError>
where
F: FnOnce(Rc<RefCell<Mainloop>>, Rc<RefCell<Context>>) -> Result<R, TalkError>,
{
let mut proplist = Proplist::new()
.ok_or_else(|| TalkError::Audio("bt_profile: failed to create PA proplist".to_string()))?;
proplist
.set_str(pa_props::APPLICATION_NAME, PA_APP_NAME)
.map_err(|_| {
TalkError::Audio("bt_profile: failed to set PA application name".to_string())
})?;
let mainloop = Mainloop::new()
.ok_or_else(|| TalkError::Audio("bt_profile: failed to create PA mainloop".to_string()))?;
let mainloop = Rc::new(RefCell::new(mainloop));
let context = {
let ml = mainloop.borrow();
Context::new_with_proplist(&*ml, PA_APP_NAME, &proplist).ok_or_else(|| {
TalkError::Audio("bt_profile: failed to create PA context".to_string())
})?
};
let context = Rc::new(RefCell::new(context));
context
.borrow_mut()
.connect(None, ContextFlagSet::NOFLAGS, None)
.map_err(|err| {
TalkError::Audio(format!("bt_profile: PA context connect failed: {}", err))
})?;
loop {
match mainloop.borrow_mut().iterate(false) {
IterateResult::Quit(_) | IterateResult::Err(_) => {
return Err(TalkError::Audio(
"bt_profile: PA mainloop terminated during connect".to_string(),
));
}
IterateResult::Success(_) => {}
}
match context.borrow().get_state() {
ContextState::Ready => break,
ContextState::Failed | ContextState::Terminated => {
return Err(TalkError::Audio(
"bt_profile: PA context failed to reach Ready state".to_string(),
));
}
_ => {}
}
}
let result = f(mainloop.clone(), context.clone());
context.borrow_mut().disconnect();
result
}
fn list_cards() -> Result<Vec<CardSnapshot>, TalkError> {
with_pulse_context(|mainloop, context| {
let cards: Rc<RefCell<Vec<CardSnapshot>>> = Rc::new(RefCell::new(Vec::new()));
let cards_inner = cards.clone();
let done = Rc::new(RefCell::new(false));
let done_inner = done.clone();
let op = context.borrow().introspect().get_card_info_list(
move |result: ListResult<&CardInfo>| match result {
ListResult::Item(card) => {
if let Some(snap) = card_info_to_snapshot(card) {
cards_inner.borrow_mut().push(snap);
}
}
ListResult::End | ListResult::Error => {
*done_inner.borrow_mut() = true;
}
},
);
loop {
match mainloop.borrow_mut().iterate(false) {
IterateResult::Quit(_) | IterateResult::Err(_) => {
return Err(TalkError::Audio(
"bt_profile: PA mainloop terminated during list_cards".to_string(),
));
}
IterateResult::Success(_) => {}
}
if *done.borrow() || op.get_state() == OperationState::Done {
break;
}
if op.get_state() == OperationState::Cancelled {
return Err(TalkError::Audio(
"bt_profile: list_cards operation cancelled".to_string(),
));
}
}
let snapshots = cards.borrow().clone();
Ok(snapshots)
})
}
fn card_info_to_snapshot(card: &CardInfo) -> Option<CardSnapshot> {
let name = card.name.as_deref()?.to_string();
let form_factor = card.proplist.get_str(pa_props::DEVICE_FORM_FACTOR);
let active_profile = card
.active_profile
.as_ref()
.and_then(|p| p.name.as_deref())
.unwrap_or("")
.to_string();
let profiles = card
.profiles
.iter()
.filter_map(|p| p.name.as_deref().map(str::to_string))
.collect();
Some(CardSnapshot {
name,
form_factor,
active_profile,
profiles,
})
}
fn set_card_profile(card_name: &str, profile: &str) -> Result<(), TalkError> {
with_pulse_context(|mainloop, context| {
let done = Rc::new(RefCell::new(false));
let success = Rc::new(RefCell::new(false));
let done_cb = done.clone();
let success_cb = success.clone();
let op = context.borrow_mut().introspect().set_card_profile_by_name(
card_name,
profile,
Some(Box::new(move |ok| {
*success_cb.borrow_mut() = ok;
*done_cb.borrow_mut() = true;
})),
);
loop {
match mainloop.borrow_mut().iterate(false) {
IterateResult::Quit(_) | IterateResult::Err(_) => {
return Err(TalkError::Audio(
"bt_profile: PA mainloop terminated during set_card_profile".to_string(),
));
}
IterateResult::Success(_) => {}
}
if *done.borrow() {
break;
}
if op.get_state() == OperationState::Cancelled {
return Err(TalkError::Audio(
"bt_profile: set_card_profile operation cancelled".to_string(),
));
}
}
if *success.borrow() {
Ok(())
} else {
Err(TalkError::Audio(format!(
"bt_profile: PulseAudio refused to switch card '{}' to profile '{}'",
card_name, profile
)))
}
})
}
#[doc(hidden)]
pub fn list_cards_for_test() -> Result<Vec<String>, TalkError> {
let cards = list_cards()?;
Ok(cards
.into_iter()
.map(|c| {
format!(
"name={} form_factor={:?} active_profile={} profiles={:?}",
c.name, c.form_factor, c.active_profile, c.profiles
)
})
.collect())
}
#[doc(hidden)]
pub fn set_card_profile_for_test(card: &str, profile: &str) -> Result<(), TalkError> {
set_card_profile(card, profile)
}
#[doc(hidden)]
pub fn get_active_profile_for_test(card_name: &str) -> Result<Option<String>, TalkError> {
let cards = list_cards()?;
Ok(cards
.into_iter()
.find(|c| c.name == card_name)
.map(|c| c.active_profile))
}
#[doc(hidden)]
pub fn find_headset_for_test() -> Result<Option<(String, String, Vec<String>)>, TalkError> {
let cards = list_cards()?;
Ok(find_headset_card(&cards)
.map(|c| (c.name.clone(), c.active_profile.clone(), c.profiles.clone())))
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(
name: &str,
form_factor: Option<&str>,
active: &str,
profiles: &[&str],
) -> CardSnapshot {
CardSnapshot {
name: name.to_string(),
form_factor: form_factor.map(str::to_string),
active_profile: active.to_string(),
profiles: profiles.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn pick_hfp_prefers_msbc_when_available() {
let avail: Vec<String> = [
"a2dp_sink",
"headset-head-unit-cvsd",
"headset-head-unit-msbc",
]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(pick_hfp_profile(&avail), Some("headset-head-unit-msbc"));
}
#[test]
fn pick_hfp_falls_back_to_cvsd_when_no_msbc() {
let avail: Vec<String> = ["a2dp_sink", "headset-head-unit-cvsd"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(pick_hfp_profile(&avail), Some("headset-head-unit-cvsd"));
}
#[test]
fn pick_hfp_falls_back_to_generic_when_no_codec_variant() {
let avail: Vec<String> = ["a2dp_sink", "headset-head-unit"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(pick_hfp_profile(&avail), Some("headset-head-unit"));
}
#[test]
fn pick_hfp_returns_none_when_no_hfp_profile() {
let avail: Vec<String> = ["a2dp_sink", "off"].iter().map(|s| s.to_string()).collect();
assert_eq!(pick_hfp_profile(&avail), None);
}
#[test]
fn pick_hfp_returns_none_for_empty() {
assert_eq!(pick_hfp_profile(&[]), None);
}
#[test]
fn find_headset_picks_form_factor_headset() {
let cards = vec![
snap(
"alsa_card.pci",
Some("internal"),
"output:hdmi",
&["output:hdmi"],
),
snap(
"bluez_card.AA_BB_CC_DD_EE_FF",
Some("headset"),
"a2dp_sink",
&["a2dp_sink", "headset-head-unit-msbc"],
),
];
let found = find_headset_card(&cards).expect("headset should be found");
assert_eq!(found.name, "bluez_card.AA_BB_CC_DD_EE_FF");
}
#[test]
fn find_headset_falls_back_to_bluez_name_when_no_form_factor() {
let cards = vec![snap(
"bluez_card.AA_BB",
None,
"a2dp_sink",
&["a2dp_sink", "headset-head-unit-cvsd"],
)];
assert!(find_headset_card(&cards).is_some());
}
#[test]
fn find_headset_ignores_bluez_without_hfp_profile() {
let cards = vec![snap(
"bluez_card.SPEAKER",
None,
"a2dp_sink",
&["a2dp_sink"],
)];
assert!(find_headset_card(&cards).is_none());
}
#[test]
fn find_headset_ignores_non_headset_form_factor() {
let cards = vec![snap(
"alsa_card.pci",
Some("internal"),
"output:analog",
&["output:analog", "input:analog"],
)];
assert!(find_headset_card(&cards).is_none());
}
#[test]
fn find_headset_returns_first_match_when_multiple() {
let cards = vec![
snap(
"bluez_card.FIRST",
Some("headset"),
"a2dp_sink",
&["a2dp_sink", "headset-head-unit-msbc"],
),
snap(
"bluez_card.SECOND",
Some("headset"),
"a2dp_sink",
&["a2dp_sink", "headset-head-unit-msbc"],
),
];
let found = find_headset_card(&cards).expect("headset should be found");
assert_eq!(found.name, "bluez_card.FIRST");
}
#[test]
fn find_headset_returns_none_for_empty() {
assert!(find_headset_card(&[]).is_none());
}
#[test]
fn saved_profile_json_round_trip() {
let saved = SavedProfile {
card_name: "bluez_card.AA_BB".to_string(),
original_profile: "a2dp_sink".to_string(),
switched_at: chrono::Utc::now(),
};
let json = serde_json::to_string(&saved).expect("serialize");
let back: SavedProfile = serde_json::from_str(&json).expect("deserialize");
assert_eq!(saved, back);
}
#[test]
fn state_file_path_uses_xdg_runtime_dir() {
let path = state_file_path().expect("path resolves");
assert!(path.ends_with(STATE_FILE_NAME));
assert!(path.parent().expect("has parent").ends_with("talk-rs"));
}
#[test]
fn headset_guard_with_none_is_noop_on_drop() {
let g = HeadsetGuard::new(None);
drop(g);
}
#[tokio::test]
async fn restore_now_async_with_none_is_noop_and_clears_guard() {
let mut g = HeadsetGuard::new(None);
g.restore_now_async();
assert!(g.saved.is_none());
drop(g);
}
#[tokio::test]
async fn restore_now_async_takes_saved_profile() {
let saved = SavedProfile {
card_name: "bluez_card.UNIT_TEST".to_string(),
original_profile: "a2dp_sink".to_string(),
switched_at: chrono::Utc::now(),
};
let mut g = HeadsetGuard::new(Some(saved));
assert!(g.saved.is_some());
g.restore_now_async();
assert!(
g.saved.is_none(),
"restore_now_async must take() the saved profile"
);
tokio::task::yield_now().await;
}
}