use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use log::{info, warn};
use crate::device::config::SocFamily;
mod discover;
pub use discover::bt_target_device;
pub use discover::PairedDevice;
use discover::{
clear_cached_bt_device, discover_connected_paired_device, discover_paired_devices,
set_cached_bt_device, set_last_ok_device,
};
static LAST_BT_TOGGLE_MS: AtomicU64 = AtomicU64::new(0);
static RECONNECT_BUSY: AtomicU8 = AtomicU8::new(0);
static BT_RECONNECT_RESULT: AtomicU8 = AtomicU8::new(0);
pub fn bt_take_reconnect_result() -> u8 {
BT_RECONNECT_RESULT.swap(0, Ordering::Relaxed)
}
pub fn bt_reconnect_busy() -> bool {
RECONNECT_BUSY.load(Ordering::SeqCst) != 0
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn bt_toggle_age_ms() -> u64 {
let t = LAST_BT_TOGGLE_MS.load(Ordering::Relaxed);
if t == 0 {
u64::MAX
} else {
now_ms().saturating_sub(t)
}
}
static BT_BUS: OnceLock<&'static str> = OnceLock::new();
const BT_BUS_MTK: &str = "com.kobo.mtk.bluedroid";
const BT_BUS_BLUEZ: &str = "org.bluez";
pub fn set_bt_bus(soc: SocFamily) {
let bus = match soc {
SocFamily::Mtk => BT_BUS_MTK,
SocFamily::Nxp | SocFamily::Sunxi => BT_BUS_BLUEZ,
};
let _ = BT_BUS.set(bus);
info!("bt: bus = {} ({:?})", bus, soc);
}
pub fn bt_bus() -> &'static str {
BT_BUS.get().copied().unwrap_or(BT_BUS_MTK)
}
const DBUS_DEVICE1_IFACE: &str = "string:org.bluez.Device1";
const DBUS_ADAPTER1_IFACE: &str = "string:org.bluez.Adapter1";
const DBUS_PROPS_GET: &str = "org.freedesktop.DBus.Properties.Get";
const DBUS_PROPS_SET: &str = "org.freedesktop.DBus.Properties.Set";
const DBUS_DEVICE1_PATH: &str = "/org/bluez/hci0";
pub(super) const DBUS_OBJECT_MANAGER: &str = "org.freedesktop.DBus.ObjectManager.GetManagedObjects";
const DBUS_DEVICE1_CONNECT: &str = "org.bluez.Device1.Connect";
const DBUS_DEVICE1_DISCONNECT: &str = "org.bluez.Device1.Disconnect";
pub(super) fn dbus_cmd() -> Command {
let dest = format!("--dest={}", bt_bus());
let mut cmd = Command::new("dbus-send");
cmd.args(["--system", "--print-reply", "--type=method_call"]);
cmd.arg(dest);
cmd
}
fn device_connected(dev: &str) -> bool {
let out = dbus_cmd()
.args([dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Connected"])
.output();
match out {
Ok(o) if o.status.success() => dbus_connected(&String::from_utf8_lossy(&o.stdout)),
_ => false,
}
}
pub fn bt_status() -> bool {
let dev = match bt_target_device() {
Some(d) => d,
None => return false,
};
if device_connected(&dev) {
set_last_ok_device(&dev);
return true;
}
if let Some(connected) = discover_connected_paired_device() {
if connected != dev {
info!(
"bt: switching target {} -> {} (already connected by btservice)",
dev, connected
);
set_cached_bt_device(&connected);
set_last_ok_device(&connected);
return true;
}
set_last_ok_device(&connected);
}
false
}
pub fn default_bt_device(content: &str) -> Option<String> {
content
.lines()
.find_map(|l| {
l.strip_prefix("DefaultAudioDevice=")
.map(|s| s.trim().to_string())
})
.filter(|d| !d.is_empty())
}
pub fn dbus_connected(text: &str) -> bool {
text.contains("boolean true")
}
pub fn bt_toggle(on: bool) {
LAST_BT_TOGGLE_MS.store(now_ms(), Ordering::Relaxed);
if on {
if let Err(e) = dbus_cmd()
.args([
DBUS_DEVICE1_PATH,
DBUS_PROPS_SET,
DBUS_ADAPTER1_IFACE,
"string:Powered",
"variant:boolean:true",
])
.status()
{
warn!("bt: adapter power-on failed: {e}");
}
info!("bt: adapter powered on (bus={})", bt_bus());
let _ = std::thread::spawn(reconnect_bt);
info!("bt: turned ON + reconnecting");
} else {
std::thread::spawn(move || {
if let Some(dev) = bt_target_device() {
if let Err(e) = dbus_cmd()
.args([&dev, DBUS_DEVICE1_DISCONNECT])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
{
warn!("bt: disconnect {dev} failed: {e}");
}
}
clear_cached_bt_device();
if let Err(e) = dbus_cmd()
.args([
DBUS_DEVICE1_PATH,
DBUS_PROPS_SET,
DBUS_ADAPTER1_IFACE,
"string:Powered",
"variant:boolean:false",
])
.status()
{
warn!("bt: adapter power-off failed: {e}");
}
info!("bt: turned OFF (adapter powered down)");
});
}
}
pub fn reconnect_bt() {
if RECONNECT_BUSY.swap(1, Ordering::SeqCst) != 0 {
info!("reconnect_bt: already running, skipping");
return;
}
let mut dev: Option<String> = None;
info!("reconnect_bt: connecting on bus={}", bt_bus());
for attempt in 1..=15 {
if dev.is_none() {
dev = bt_target_device();
if dev.is_none() {
std::thread::sleep(std::time::Duration::from_millis(400));
continue;
}
}
let dev_str = dev.as_ref().unwrap();
let _ = dbus_cmd()
.args([dev_str, DBUS_DEVICE1_CONNECT])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let connected = bt_status();
if connected {
info!("reconnect_bt: connected on attempt {attempt}");
BT_RECONNECT_RESULT.store(1, Ordering::Relaxed);
RECONNECT_BUSY.store(0, Ordering::SeqCst);
return;
}
if attempt == 5 {
if let Some(d) = bt_target_device() {
if Some(&d) != dev.as_ref() {
info!("reconnect_bt: switching target {:?} -> {d}", dev);
dev = Some(d);
}
}
}
std::thread::sleep(std::time::Duration::from_millis(400));
}
info!("reconnect_bt: gave up after 15 attempts");
BT_RECONNECT_RESULT.store(2, Ordering::Relaxed);
RECONNECT_BUSY.store(0, Ordering::SeqCst);
}
pub fn bt_name() -> Option<String> {
if !bt_status() {
return None;
}
let dev = bt_target_device()?;
let out = dbus_cmd()
.args([&dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Alias"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
parse_dbus_string(&text).or_else(|| {
let out2 = dbus_cmd()
.args([&dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Name"])
.output()
.ok()?;
parse_dbus_string(&String::from_utf8_lossy(&out2.stdout))
})
}
pub fn parse_dbus_string(text: &str) -> Option<String> {
let idx = text.find("string \"")?;
let rest = &text[idx + 8..];
let end = rest.find('"')?;
let name = &rest[..end];
(!name.is_empty()).then(|| name.to_string())
}
pub struct BtDeviceInfo {
pub path: String,
pub name: String,
pub connected: bool,
}
fn device_alias(dev: &str) -> Option<String> {
let out = dbus_cmd()
.args([dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Alias"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
parse_dbus_string(&text).or_else(|| {
let out2 = dbus_cmd()
.args([dev, DBUS_PROPS_GET, DBUS_DEVICE1_IFACE, "string:Name"])
.output()
.ok()?;
parse_dbus_string(&String::from_utf8_lossy(&out2.stdout))
})
}
pub fn bt_list_devices() -> Vec<BtDeviceInfo> {
let paired = discover_paired_devices();
paired
.into_iter()
.map(|d| {
let name = device_alias(&d.path).unwrap_or_else(|| {
d.path
.rsplit('/')
.next()
.unwrap_or("Unknown")
.replace('_', ":")
});
BtDeviceInfo {
name,
connected: d.connected,
path: d.path,
}
})
.collect()
}
pub fn bt_set_target_device(path: &str) {
set_cached_bt_device(path);
}
pub fn bt_connect_device(path: &str) {
info!("bt: switching to device {path}");
if let Some(current) = bt_target_device() {
if current != path {
let _ = dbus_cmd()
.args([¤t, DBUS_DEVICE1_DISCONNECT])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
set_cached_bt_device(path);
let path = path.to_string();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
for _ in 1..=4 {
let _ = dbus_cmd()
.args([&path, DBUS_DEVICE1_CONNECT])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let connected = device_connected(&path);
if connected {
return;
}
std::thread::sleep(std::time::Duration::from_millis(1500));
}
info!("bt_connect_device: gave up after 4 attempts ({path})");
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_bt_device_extracts_first_entry() {
let cfg = "[Audio]\nDefaultAudioDevice=/org/bluez/hci0/dev_AA_BB\nFoo=bar\n";
assert_eq!(
default_bt_device(cfg).as_deref(),
Some("/org/bluez/hci0/dev_AA_BB")
);
}
#[test]
fn default_bt_device_ignores_empty_value() {
assert_eq!(default_bt_device("DefaultAudioDevice=\n"), None);
assert_eq!(default_bt_device("DefaultAudioDevice= \n"), None);
assert_eq!(default_bt_device("[Section]\n"), None);
}
#[test]
fn dbus_connected_detects_true() {
assert!(dbus_connected(" variant boolean true\n"));
assert!(!dbus_connected(" variant boolean false\n"));
assert!(!dbus_connected(""));
}
#[test]
fn parse_dbus_string_extracts_value() {
let out = " variant string \"JBL Flip\"\n";
assert_eq!(parse_dbus_string(out).as_deref(), Some("JBL Flip"));
}
#[test]
fn parse_dbus_string_none_when_empty_or_absent() {
assert_eq!(parse_dbus_string("string \"\"\n"), None);
assert_eq!(parse_dbus_string("no string field"), None);
}
}