use bluer::{
Adapter, Address, Session,
agent::{Agent, ReqError},
};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Flex, Layout},
style::{Color, Style, Stylize},
text::Line,
widgets::{Block, BorderType, Clear, Paragraph, Row, Table, Widget},
};
use std::{collections::HashMap, io, time::Duration};
use tokio::{
sync::{mpsc, oneshot},
task::JoinHandle,
};
use tokio_stream::StreamExt;
#[derive(Debug, Clone)]
pub struct DeviceInfo {
pub address: Address,
pub name: Option<String>,
pub paired: Option<bool>,
pub connected: Option<bool>,
}
#[derive(Debug)]
pub struct PairingRequest {
pub address: Address,
pub respond: oneshot::Sender<bool>,
}
#[derive(Debug, Clone, Default)]
struct Bluetooth {
session: Option<Session>,
adapter: Option<Adapter>,
devices: HashMap<Address, DeviceInfo>,
}
impl Bluetooth {
pub async fn init(
&mut self,
pairing_tx: mpsc::UnboundedSender<PairingRequest>,
) -> anyhow::Result<()> {
let session = bluer::Session::new().await?;
let adapter = session.default_adapter().await?;
adapter.set_powered(true).await?;
let ask = move |address: Address| {
let pairing_tx = pairing_tx.clone();
async move {
let (respond, response) = oneshot::channel();
if pairing_tx
.send(PairingRequest { address, respond })
.is_err()
{
return Err(ReqError::Rejected);
}
match response.await {
Ok(true) => Ok(()),
_ => Err(ReqError::Rejected),
}
}
};
let ask_confirmation = ask.clone();
let ask_authorization = ask;
let agent = Agent {
request_default: true,
request_confirmation: Some(Box::new(move |request| {
Box::pin(ask_confirmation(request.device))
})),
request_authorization: Some(Box::new(move |request| {
Box::pin(ask_authorization(request.device))
})),
authorize_service: Some(Box::new(|_| Box::pin(async { Ok(()) }))),
..Default::default()
};
let agent_handle = session.register_agent(agent).await?;
tokio::spawn(async move {
let _agent_handle = agent_handle;
std::future::pending::<()>().await
});
self.session = Some(session);
self.adapter = Some(adapter);
Ok(())
}
pub async fn scan(&self, tx: mpsc::UnboundedSender<DeviceInfo>) -> anyhow::Result<()> {
let adapter = self
.adapter
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Bluetooth not initialized"))?;
let mut discovery = adapter.discover_devices().await?;
let scan = async {
while let Some(event) = discovery.next().await {
if let bluer::AdapterEvent::DeviceAdded(address) = event {
let device = adapter.device(address)?;
let name = device.name().await.ok().flatten();
let paired = device.is_paired().await.ok();
let connected = device.is_connected().await.ok();
let info = DeviceInfo {
address,
name,
paired,
connected,
};
let _ = tx.send(info);
}
}
Ok::<_, anyhow::Error>(())
};
if let Ok(result) = tokio::time::timeout(Duration::from_secs(10), scan).await {
result?;
}
Ok(())
}
pub async fn watch(&self, tx: mpsc::UnboundedSender<DeviceInfo>) -> anyhow::Result<()> {
let adapter = self
.adapter
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Bluetooth not initialized"))?;
let spawn_watch = |address: Address| {
let adapter = adapter.clone();
let tx = tx.clone();
tokio::spawn(async move {
let device = adapter.device(address)?;
let mut events = Box::pin(device.events().await?);
while let Some(event) = events.next().await {
if let bluer::DeviceEvent::PropertyChanged(
bluer::DeviceProperty::Connected(_)
| bluer::DeviceProperty::Paired(_)
| bluer::DeviceProperty::Name(_),
) = event
{
let info = DeviceInfo {
address,
name: device.name().await.ok().flatten(),
paired: device.is_paired().await.ok(),
connected: device.is_connected().await.ok(),
};
let _ = tx.send(info);
}
}
Ok::<_, anyhow::Error>(())
});
};
let mut events = Box::pin(adapter.events().await?);
for address in adapter.device_addresses().await? {
spawn_watch(address);
}
while let Some(event) = events.next().await {
if let bluer::AdapterEvent::DeviceAdded(address) = event {
spawn_watch(address);
}
}
Ok(())
}
pub async fn pair(&self, address: Address) -> anyhow::Result<()> {
let adapter = self
.adapter
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Bluetooth not initialized"))?;
if !adapter.device_addresses().await?.contains(&address) {
let mut discovery = adapter.discover_devices().await?;
let found = async {
while let Some(event) = discovery.next().await {
if let bluer::AdapterEvent::DeviceAdded(added) = event {
if added == address {
return true;
}
}
}
false
};
if !tokio::time::timeout(Duration::from_secs(15), found)
.await
.unwrap_or(false)
{
anyhow::bail!("Device not found, is it in pairing mode?");
}
}
let device = adapter.device(address)?;
if !device.is_paired().await? {
device.pair().await?;
}
device.set_trusted(true).await?;
if !device.is_connected().await? {
device.connect().await?;
}
Ok(())
}
pub async fn unpair(&self, address: Address) -> anyhow::Result<()> {
let adapter = self
.adapter
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Bluetooth not initialized"))?;
adapter.remove_device(address).await?;
Ok(())
}
pub async fn connect(&self, address: Address) -> anyhow::Result<()> {
let adapter = self
.adapter
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Bluetooth not initialized"))?;
adapter.device(address)?.connect().await?;
Ok(())
}
pub async fn disconnect(&self, address: Address) -> anyhow::Result<()> {
let adapter = self
.adapter
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Bluetooth not initialized"))?;
adapter.device(address)?.disconnect().await?;
Ok(())
}
}
#[derive(Debug)]
pub struct BluetoothTUI {
exit: bool,
bt: Option<Bluetooth>,
scanning: bool,
selection_number: usize,
total_devices: usize,
init_handle: Option<JoinHandle<anyhow::Result<Bluetooth>>>,
scan_handle: Option<JoinHandle<anyhow::Result<()>>>,
action_handle: Option<JoinHandle<anyhow::Result<DeviceInfo>>>,
action_address: Option<Address>,
scan_rx: Option<mpsc::UnboundedReceiver<DeviceInfo>>,
watch_rx: Option<mpsc::UnboundedReceiver<DeviceInfo>>,
pairing_rx: mpsc::UnboundedReceiver<PairingRequest>,
pairing_request: Option<PairingRequest>,
}
impl BluetoothTUI {
pub fn new() -> Self {
let (pairing_tx, pairing_rx) = mpsc::unbounded_channel();
let init_handle = tokio::spawn(async move {
let mut bt = Bluetooth::default();
bt.init(pairing_tx).await?;
Ok(bt)
});
Self {
exit: false,
bt: None,
scanning: false,
selection_number: 0,
total_devices: 0,
init_handle: Some(init_handle),
scan_handle: None,
action_handle: None,
action_address: None,
scan_rx: None,
watch_rx: None,
pairing_rx,
pairing_request: None,
}
}
pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
while !self.exit {
while let Ok(request) = self.pairing_rx.try_recv() {
if self.pairing_request.is_none() {
self.pairing_request = Some(request);
} else {
let _ = request.respond.send(false);
}
}
if self
.pairing_request
.as_ref()
.is_some_and(|request| request.respond.is_closed())
{
self.pairing_request = None;
}
if let Some(rx) = &mut self.scan_rx {
while let Ok(device) = rx.try_recv() {
if let Some(bt) = &mut self.bt {
let entry = bt
.devices
.entry(device.address)
.or_insert_with(|| device.clone());
if device.name.is_some() {
entry.name = device.name;
}
}
}
}
if let Some(rx) = &mut self.watch_rx {
while let Ok(device) = rx.try_recv() {
if self.action_handle.is_some() && self.action_address == Some(device.address) {
continue;
}
if let Some(bt) = &mut self.bt {
let entry = bt
.devices
.entry(device.address)
.or_insert_with(|| device.clone());
entry.paired = device.paired;
entry.connected = device.connected;
if device.name.is_some() {
entry.name = device.name;
}
}
}
}
if self.bt.is_none() && self.init_handle.as_ref().is_some_and(|h| h.is_finished()) {
let init_handle = self.init_handle.take().unwrap();
let bt = init_handle.await??;
let scan_bt = bt.clone();
let (tx, rx) = mpsc::unbounded_channel();
self.scan_rx = Some(rx);
self.scanning = true;
self.scan_handle = Some(tokio::spawn(async move { scan_bt.scan(tx).await }));
let watch_bt = bt.clone();
let (watch_tx, watch_rx) = mpsc::unbounded_channel();
self.watch_rx = Some(watch_rx);
tokio::spawn(async move { watch_bt.watch(watch_tx).await });
self.bt = Some(bt);
}
if let Some(bt) = &self.bt {
self.total_devices = bt.devices.len();
}
if self
.scan_handle
.as_ref()
.is_some_and(|handle| handle.is_finished())
{
let _ = self.scan_handle.take().unwrap().await;
self.scanning = false;
}
if self
.action_handle
.as_ref()
.is_some_and(|handle| handle.is_finished())
{
self.action_address = None;
if let Ok(Ok(info)) = self.action_handle.take().unwrap().await {
if let Some(bt) = &mut self.bt {
bt.devices.insert(info.address, info);
}
}
}
terminal.draw(|frame| self.draw(frame))?;
self.handle_events().await?;
}
Ok(())
}
fn draw(&self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
async fn handle_events(&mut self) -> io::Result<()> {
if event::poll(Duration::ZERO)? {
match event::read()? {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event).await;
}
_ => {}
}
} else {
tokio::time::sleep(Duration::from_millis(50)).await;
}
Ok(())
}
async fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.pairing_request.is_some() {
match key_event.code {
KeyCode::Char(' ') => {
if let Some(request) = self.pairing_request.take() {
let _ = request.respond.send(true);
}
}
KeyCode::Char('q') => self.exit(),
_ => {}
}
return;
}
match key_event.code {
KeyCode::Char('q') => self.exit(),
KeyCode::Char('s') => {
if self.scanning {
return;
}
if let Some(bt) = &mut self.bt {
bt.devices = HashMap::new();
let bt = bt.clone();
let (tx, rx) = mpsc::unbounded_channel();
self.scan_rx = Some(rx);
self.scanning = true;
self.scan_handle = Some(tokio::spawn(async move { bt.scan(tx).await }));
}
}
KeyCode::Up => match self.selection_number {
0 => self.selection_number = self.total_devices.saturating_sub(1),
_ => self.selection_number -= 1,
},
KeyCode::Down => match self.selection_number {
n if n + 1 >= self.total_devices => self.selection_number = 0,
_ => self.selection_number += 1,
},
KeyCode::Char('u') => {
if self.action_handle.is_some() {
return;
}
if let Some(bt) = &self.bt {
let mut devices = bt.devices.values().collect::<Vec<_>>();
devices.sort_by(|a, b| match (&a.name, &b.name) {
(None, None) => a.address.cmp(&b.address),
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(_), None) => std::cmp::Ordering::Less,
(Some(an), Some(bn)) => an.cmp(bn).then_with(|| a.address.cmp(&b.address)),
});
if let Some(device) = devices.get(self.selection_number) {
let address = device.address;
let name = device.name.clone();
let bt = bt.clone();
self.action_handle = Some(tokio::spawn(async move {
bt.unpair(address).await?;
Ok(DeviceInfo {
address,
name,
paired: Some(false),
connected: Some(false),
})
}));
}
}
}
KeyCode::Char(' ') => {
if self.action_handle.is_some() {
return;
}
if let Some(bt) = &self.bt {
let mut devices = bt.devices.values().collect::<Vec<_>>();
devices.sort_by(|a, b| match (&a.name, &b.name) {
(None, None) => a.address.cmp(&b.address),
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(_), None) => std::cmp::Ordering::Less,
(Some(an), Some(bn)) => an.cmp(bn).then_with(|| a.address.cmp(&b.address)),
});
if let Some(device) = devices.get(self.selection_number) {
let address = device.address;
let name = device.name.clone();
let paired = device.paired == Some(true);
let connected = device.connected == Some(true);
let bt = bt.clone();
self.action_address = Some(address);
self.action_handle = Some(tokio::spawn(async move {
if connected {
bt.disconnect(address).await?;
} else if paired {
bt.connect(address).await?;
} else {
bt.pair(address).await?;
}
let device = bt
.adapter
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Bluetooth not initialized"))?
.device(address)?;
Ok(DeviceInfo {
address,
name,
paired: device.is_paired().await.ok(),
connected: device.is_connected().await.ok(),
})
}));
}
}
}
_ => {}
}
}
fn exit(&mut self) {
self.exit = true
}
}
impl Widget for &BluetoothTUI {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) {
let title = " Bluetooth ".black().on_blue();
let init_status = if self.bt.is_none() {
Line::from(" Connecting...".red())
} else {
let bt = self.bt.as_ref().unwrap();
let adapter = bt.adapter.as_ref().unwrap();
Line::from(vec![
" Connected: ".bold().green(),
adapter.name().bold().yellow(),
" ".into(),
])
};
let scan_status = if self.action_handle.is_some() {
Line::from(" Working ".bold().yellow())
} else if self.scanning {
Line::from(" Scanning ".bold().green())
} else {
Line::from("")
};
let keybinds = Line::from(vec![
" Pair/Connect/Disconnect ".white(),
"<space>".blue().bold(),
" Unpair ".white(),
"<u>".blue().bold(),
" Scan ".white(),
"<s>".blue().bold(),
" Quit ".white(),
"<q> ".blue().bold(),
]);
let mut rows = if let Some(bt) = &self.bt {
let mut devices = bt.devices.values().collect::<Vec<_>>();
devices.sort_by(|a, b| match (&a.name, &b.name) {
(None, None) => a.address.cmp(&b.address),
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(_), None) => std::cmp::Ordering::Less,
(Some(an), Some(bn)) => an.cmp(bn).then_with(|| a.address.cmp(&b.address)),
});
devices
.into_iter()
.enumerate()
.map(|(i, device)| {
let mut row = Row::new([
Line::from(device.name.clone().unwrap_or_else(|| "Unknown".to_string()))
.right_aligned()
.green(),
Line::from(device.address.to_string())
.left_aligned()
.yellow(),
Line::from(match device.paired {
Some(paired) => {
if paired {
"yes".green()
} else {
"no".red()
}
}
None => "idk".blue(),
})
.right_aligned(),
Line::from(
if self.action_address == Some(device.address)
&& device.paired != Some(true)
{
"pairing".yellow()
} else {
match device.connected {
Some(connected) => {
if connected {
"yes".green()
} else {
"no".red()
}
}
None => "idk".blue(),
}
},
)
.left_aligned()
.red(),
]);
if i == self.selection_number {
row = row.style(
Style::new()
.underlined()
.underline_color(Color::Blue)
.bold(),
);
}
row
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
rows.insert(
0,
Row::new([
Line::from("Name".bold()).right_aligned(),
Line::from("Address".bold()).left_aligned(),
Line::from("Paired".bold()).right_aligned(),
Line::from("Connected".bold()).left_aligned(),
]),
);
let scan_table = Table::new(
rows,
[
Constraint::Fill(4),
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(4),
],
);
let block = Block::bordered()
.title(title.into_centered_line())
.title_bottom(init_status.left_aligned())
.title_bottom(keybinds.centered())
.title_bottom(scan_status.right_aligned())
.border_style(Style::new().blue())
.border_type(BorderType::Double);
scan_table.block(block).render(area, buf);
if let Some(request) = &self.pairing_request {
let name = self
.bt
.as_ref()
.and_then(|bt| bt.devices.get(&request.address))
.and_then(|device| device.name.clone())
.unwrap_or_else(|| "Unknown".to_string());
let popup_width = 50.min(area.width);
let popup_height = 8.min(area.height);
let [popup_area] = Layout::vertical([Constraint::Length(popup_height)])
.flex(Flex::Center)
.areas(area);
let [popup_area] = Layout::horizontal([Constraint::Length(popup_width)])
.flex(Flex::Center)
.areas(popup_area);
let popup_title = " Pairing request ".black().on_blue();
let popup_block = Block::bordered()
.title(popup_title.into_centered_line())
.border_style(Style::new().blue())
.border_type(BorderType::Double);
let popup = Paragraph::new(vec![
Line::from(""),
Line::from(name.bold().green()).centered(),
Line::from(request.address.to_string().yellow().bold()).centered(),
Line::from(""),
Line::from(vec![
"Press ".white(),
"<space>".blue().bold(),
" to accept the pairing request.".white(),
])
.centered(),
Line::from(""),
])
.block(popup_block);
Clear.render(popup_area, buf);
popup.render(popup_area, buf);
}
}
}