use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use bluer::{Adapter, Address};
use log::{debug, info, warn};
use tokio::process::Command;
use crate::protocol::MW75_DEVICE_NAME_PATTERN;
#[allow(dead_code)]
const A2DP_SINK_UUID: bluer::Uuid = bluer::Uuid::from_u128(0x0000110b_0000_1000_8000_00805f9b34fb);
#[derive(Debug, Clone)]
pub struct AudioConfig {
pub name_pattern: String,
pub discovery_timeout_secs: u64,
pub auto_set_sink: bool,
pub volume: f32,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
name_pattern: MW75_DEVICE_NAME_PATTERN.into(),
discovery_timeout_secs: 10,
auto_set_sink: true,
volume: 0.8,
}
}
}
#[derive(Debug, Clone)]
pub struct AudioDevice {
pub name: String,
pub address: Address,
pub was_paired: bool,
pub sink_name: Option<String>,
}
pub struct Mw75Audio {
config: AudioConfig,
adapter: Option<Adapter>,
device: Option<AudioDevice>,
previous_sink: Option<String>,
}
impl Mw75Audio {
pub fn new(config: AudioConfig) -> Self {
Self {
config,
adapter: None,
device: None,
previous_sink: None,
}
}
pub async fn connect(&mut self) -> Result<AudioDevice> {
let session = bluer::Session::new()
.await
.context("Failed to connect to BlueZ D-Bus session")?;
let adapter = session
.default_adapter()
.await
.context("No Bluetooth adapter found")?;
adapter
.set_powered(true)
.await
.context("Failed to power on Bluetooth adapter")?;
info!("Bluetooth adapter: {}", adapter.name());
let (address, name) = self
.discover_mw75(&adapter)
.await
.context("MW75 discovery failed")?;
info!("Found MW75: {name} [{address}]");
let device = adapter.device(address)?;
let was_paired = device.is_paired().await.unwrap_or(false);
if !was_paired {
info!("Pairing with {name}…");
device.pair().await.context("Pairing failed")?;
info!("Paired successfully");
} else {
info!("Already paired with {name}");
}
if !device.is_trusted().await.unwrap_or(false) {
device.set_trusted(true).await.ok();
info!("Device trusted for auto-reconnect");
}
if !device.is_connected().await.unwrap_or(false) {
info!("Connecting A2DP…");
device.connect().await.context("A2DP connection failed")?;
tokio::time::sleep(Duration::from_secs(2)).await;
info!("A2DP connected");
} else {
info!("Already connected");
}
let mut audio_device = AudioDevice {
name: name.clone(),
address,
was_paired,
sink_name: None,
};
if self.config.auto_set_sink {
match self.set_as_default_sink(&address).await {
Ok(sink_name) => {
info!("Audio sink set to: {sink_name}");
audio_device.sink_name = Some(sink_name);
}
Err(e) => {
warn!("Could not set as default sink (audio may still work): {e}");
}
}
}
self.adapter = Some(adapter);
self.device = Some(audio_device.clone());
Ok(audio_device)
}
pub async fn play_file(&self, path: &str) -> Result<()> {
let path = path.to_string();
let volume = self.config.volume;
tokio::task::spawn_blocking(move || Self::play_file_sync(&path, volume))
.await
.context("Playback task panicked")?
}
pub fn play_file_sync(path: &str, volume: f32) -> Result<()> {
use rodio::{Decoder, OutputStream, Sink};
use std::fs::File;
use std::io::BufReader;
info!("Playing: {path}");
let (_stream, stream_handle) =
OutputStream::try_default().context("No audio output device available")?;
let sink = Sink::try_new(&stream_handle).context("Failed to create audio sink")?;
let file = File::open(path).with_context(|| format!("Cannot open audio file: {path}"))?;
let source = Decoder::new(BufReader::new(file))
.with_context(|| format!("Cannot decode audio file: {path}"))?;
sink.set_volume(volume.clamp(0.0, 1.0));
sink.append(source);
info!("Playback started (volume={:.0}%)", volume * 100.0);
sink.sleep_until_end();
info!("Playback finished");
Ok(())
}
pub async fn disconnect(&mut self) -> Result<()> {
if let Some(ref prev) = self.previous_sink {
info!("Restoring previous audio sink: {prev}");
let _ = run_pactl(&["set-default-sink", prev]).await;
}
if let (Some(adapter), Some(dev)) = (&self.adapter, &self.device) {
let device = adapter.device(dev.address)?;
if device.is_connected().await.unwrap_or(false) {
info!("Disconnecting A2DP from {}…", dev.name);
device.disconnect().await.ok();
info!("Disconnected");
}
}
self.device = None;
self.adapter = None;
self.previous_sink = None;
Ok(())
}
pub async fn is_connected(&self) -> bool {
if let (Some(adapter), Some(dev)) = (&self.adapter, &self.device) {
if let Ok(device) = adapter.device(dev.address) {
return device.is_connected().await.unwrap_or(false);
}
}
false
}
pub fn connected_device(&self) -> Option<&AudioDevice> {
self.device.as_ref()
}
async fn discover_mw75(&self, adapter: &Adapter) -> Result<(Address, String)> {
use futures::StreamExt;
let pattern = self.config.name_pattern.to_uppercase();
let timeout = Duration::from_secs(self.config.discovery_timeout_secs);
for addr in adapter.device_addresses().await? {
if let Ok(device) = adapter.device(addr) {
if let Ok(Some(name)) = device.name().await {
if name.to_uppercase().contains(&pattern) {
info!("Found already-known MW75: {name} [{addr}]");
return Ok((addr, name));
}
}
}
}
info!(
"Starting Bluetooth discovery (timeout: {} s)…",
self.config.discovery_timeout_secs
);
let mut discover = adapter
.discover_devices()
.await
.context("Failed to start discovery")?;
let result = tokio::time::timeout(timeout, async {
while let Some(event) = discover.next().await {
if let bluer::AdapterEvent::DeviceAdded(addr) = event {
if let Ok(device) = adapter.device(addr) {
if let Ok(Some(name)) = device.name().await {
debug!("Discovered: {name} [{addr}]");
if name.to_uppercase().contains(&pattern) {
return Ok((addr, name));
}
}
}
}
}
Err(anyhow!("Discovery stream ended without finding MW75"))
})
.await;
match result {
Ok(r) => r,
Err(_) => Err(anyhow!(
"MW75 not found within {} s — is it powered on and in range?",
self.config.discovery_timeout_secs
)),
}
}
async fn set_as_default_sink(&mut self, address: &Address) -> Result<String> {
self.previous_sink = get_default_sink().await.ok();
let addr_str = address.to_string().replace(':', "_");
let sink_name = find_bt_sink(&addr_str)
.await
.with_context(|| format!("No PulseAudio/PipeWire sink found for {address}"))?;
run_pactl(&["set-default-sink", &sink_name])
.await
.context("Failed to set default sink")?;
Ok(sink_name)
}
}
async fn run_pactl(args: &[&str]) -> Result<String> {
let output = Command::new("pactl")
.args(args)
.output()
.await
.context("Failed to run pactl — is PulseAudio/PipeWire installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"pactl {} failed: {}",
args.join(" "),
stderr.trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
async fn get_default_sink() -> Result<String> {
run_pactl(&["get-default-sink"]).await
}
async fn find_bt_sink(addr_pattern: &str) -> Result<String> {
let output = run_pactl(&["list", "sinks", "short"]).await?;
for line in output.lines() {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() >= 2 && fields[1].contains(addr_pattern) {
return Ok(fields[1].to_string());
}
}
Err(anyhow!(
"No Bluetooth sink matching '{}' in pactl output:\n{}",
addr_pattern,
output
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audio_config_defaults() {
let cfg = AudioConfig::default();
assert_eq!(cfg.name_pattern, "MW75");
assert_eq!(cfg.discovery_timeout_secs, 10);
assert!(cfg.auto_set_sink);
assert!((cfg.volume - 0.8).abs() < f32::EPSILON);
}
#[test]
fn audio_config_custom() {
let cfg = AudioConfig {
name_pattern: "TEST".into(),
discovery_timeout_secs: 30,
auto_set_sink: false,
volume: 0.5,
};
assert_eq!(cfg.name_pattern, "TEST");
assert!(!cfg.auto_set_sink);
}
#[test]
fn audio_device_clone() {
let dev = AudioDevice {
name: "MW75 Neuro".into(),
address: Address::new([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]),
was_paired: true,
sink_name: Some("bluez_output.AA_BB_CC".into()),
};
let cloned = dev.clone();
assert_eq!(cloned.name, dev.name);
assert_eq!(cloned.address, dev.address);
assert_eq!(cloned.sink_name, dev.sink_name);
}
#[test]
fn mw75_audio_initial_state() {
let audio = Mw75Audio::new(AudioConfig::default());
assert!(audio.device.is_none());
assert!(audio.adapter.is_none());
assert!(audio.previous_sink.is_none());
assert!(audio.connected_device().is_none());
}
}