linux-rust: add bt communication to ui

This commit is contained in:
Kavish Devar
2025-11-05 14:13:22 +05:30
parent 64470c4d34
commit 934df2419a
7 changed files with 263 additions and 54 deletions

View File

@@ -9,6 +9,7 @@ use ksni::Handle;
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration};
use crate::ui::tray::MyTray;
use crate::ui::messages::UIMessage;
pub struct AirPodsDevice {
pub mac_address: Address,
@@ -18,7 +19,7 @@ pub struct AirPodsDevice {
}
impl AirPodsDevice {
pub async fn new(mac_address: Address, tray_handle: Option<Handle<MyTray>>) -> Self {
pub async fn new(mac_address: Address, tray_handle: Option<Handle<MyTray>>, ui_tx: tokio::sync::mpsc::UnboundedSender<UIMessage>) -> Self {
info!("Creating new AirPodsDevice for {}", mac_address);
let mut aacp_manager = AACPManager::new();
aacp_manager.connect(mac_address).await;
@@ -146,7 +147,9 @@ impl AirPodsDevice {
let aacp_manager_clone_events = aacp_manager.clone();
let local_mac_events = local_mac.clone();
tokio::spawn(async move {
let ui_tx_clone = ui_tx.clone();
while let Some(event) = rx.recv().await {
let event_clone = event.clone();
match event {
AACPEvent::EarDetection(old_status, new_status) => {
debug!("Received EarDetection event: old_status={:?}, new_status={:?}", old_status, new_status);
@@ -178,9 +181,14 @@ impl AirPodsDevice {
}).await;
}
debug!("Updated tray with new battery info");
let _ = ui_tx_clone.send(UIMessage::AACPUIEvent(mac_address.to_string(), event_clone));
debug!("Sent BatteryInfo event to UI");
}
AACPEvent::ControlCommand(status) => {
debug!("Received ControlCommand event: {:?}", status);
let _ = ui_tx_clone.send(UIMessage::AACPUIEvent(mac_address.to_string(), event_clone));
debug!("Sent ControlCommand event to UI");
}
AACPEvent::ConversationalAwareness(status) => {
debug!("Received ConversationalAwareness event: {}", status);
@@ -218,7 +226,11 @@ impl AirPodsDevice {
controller.pause_all_media().await;
controller.deactivate_a2dp_profile().await;
}
_ => {}
_ => {
debug!("Received unhandled AACP event: {:?}", event);
let _ = ui_tx_clone.send(UIMessage::AACPUIEvent(mac_address.to_string(), event_clone));
debug!("Sent unhandled AACP event to UI");
}
}
}
});

View File

@@ -8,7 +8,6 @@ use tokio::time::{sleep, Instant};
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json;
use std::path::PathBuf;
use crate::utils::get_devices_path;
const PSM: u16 = 0x1001;
@@ -127,6 +126,49 @@ impl ControlCommandIdentifiers {
}
}
impl std::fmt::Display for ControlCommandIdentifiers {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
ControlCommandIdentifiers::MicMode => "Mic Mode",
ControlCommandIdentifiers::ButtonSendMode => "Button Send Mode",
ControlCommandIdentifiers::VoiceTrigger => "Voice Trigger",
ControlCommandIdentifiers::SingleClickMode => "Single Click Mode",
ControlCommandIdentifiers::DoubleClickMode => "Double Click Mode",
ControlCommandIdentifiers::ClickHoldMode => "Click Hold Mode",
ControlCommandIdentifiers::DoubleClickInterval => "Double Click Interval",
ControlCommandIdentifiers::ClickHoldInterval => "Click Hold Interval",
ControlCommandIdentifiers::ListeningModeConfigs => "Listening Mode Configs",
ControlCommandIdentifiers::OneBudAncMode => "One Bud ANC Mode",
ControlCommandIdentifiers::CrownRotationDirection => "Crown Rotation Direction",
ControlCommandIdentifiers::ListeningMode => "Listening Mode",
ControlCommandIdentifiers::AutoAnswerMode => "Auto Answer Mode",
ControlCommandIdentifiers::ChimeVolume => "Chime Volume",
ControlCommandIdentifiers::VolumeSwipeInterval => "Volume Swipe Interval",
ControlCommandIdentifiers::CallManagementConfig => "Call Management Config",
ControlCommandIdentifiers::VolumeSwipeMode => "Volume Swipe Mode",
ControlCommandIdentifiers::AdaptiveVolumeConfig => "Adaptive Volume Config",
ControlCommandIdentifiers::SoftwareMuteConfig => "Software Mute Config",
ControlCommandIdentifiers::ConversationDetectConfig => "Conversation Detect Config",
ControlCommandIdentifiers::Ssl => "SSL",
ControlCommandIdentifiers::HearingAid => "Hearing Aid",
ControlCommandIdentifiers::AutoAncStrength => "Auto ANC Strength",
ControlCommandIdentifiers::HpsGainSwipe => "HPS Gain Swipe",
ControlCommandIdentifiers::HrmState => "HRM State",
ControlCommandIdentifiers::InCaseToneConfig => "In Case Tone Config",
ControlCommandIdentifiers::SiriMultitoneConfig => "Siri Multitone Config",
ControlCommandIdentifiers::HearingAssistConfig => "Hearing Assist Config",
ControlCommandIdentifiers::AllowOffOption => "Allow Off Option",
ControlCommandIdentifiers::StemConfig => "Stem Config",
ControlCommandIdentifiers::SleepDetectionConfig => "Sleep Detection Config",
ControlCommandIdentifiers::AllowAutoConnect => "Allow Auto Connect",
ControlCommandIdentifiers::EarDetectionConfig => "Ear Detection Config",
ControlCommandIdentifiers::AutomaticConnectionConfig => "Automatic Connection Config",
ControlCommandIdentifiers::OwnsConnection => "Owns Connection",
};
write!(f, "{}", name)
}
}
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum ProximityKeyType {

View File

@@ -19,6 +19,7 @@ use crate::ui::tray::MyTray;
use clap::Parser;
use crate::bluetooth::le::start_le_monitor;
use tokio::sync::mpsc::unbounded_channel;
use crate::ui::messages::UIMessage;
#[derive(Parser)]
struct Args {
@@ -34,11 +35,11 @@ fn main() -> iced::Result {
let args = Args::parse();
let log_level = if args.debug { "debug" } else { "info" };
if env::var("RUST_LOG").is_err() {
unsafe { env::set_var("RUST_LOG", log_level); }
unsafe { env::set_var("RUST_LOG", log_level.to_owned() + ",wgpu_core=off,librepods_rust::bluetooth::le=off,cosmic_text=off,naga=off,iced_winit=off") };
}
env_logger::init();
let (ui_tx, ui_rx) = unbounded_channel::<()>();
let (ui_tx, ui_rx) = unbounded_channel::<UIMessage>();
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async_main(ui_tx)).unwrap();
@@ -48,7 +49,7 @@ fn main() -> iced::Result {
}
async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender<()>) -> bluer::Result<()> {
async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender<UIMessage>) -> bluer::Result<()> {
let args = Args::parse();
let tray_handle = if args.no_tray {
@@ -66,7 +67,7 @@ async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender<()>) -> bluer::Res
listening_mode: None,
allow_off_option: None,
command_tx: None,
ui_tx: Some(ui_tx),
ui_tx: Some(ui_tx.clone()),
};
let handle = tray.spawn().await.unwrap();
Some(handle)
@@ -91,7 +92,9 @@ async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender<()>) -> bluer::Res
Ok(device) => {
let name = device.name().await?.unwrap_or_else(|| "Unknown".to_string());
info!("Found connected AirPods: {}, initializing.", name);
let _airpods_device = AirPodsDevice::new(device.address(), tray_handle.clone()).await;
let ui_tx_clone = ui_tx.clone();
ui_tx_clone.send(UIMessage::DeviceConnected(device.address().to_string())).unwrap();
let _airpods_device = AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx_clone).await;
}
Err(_) => {
info!("No connected AirPods found.");
@@ -128,8 +131,10 @@ async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender<()>) -> bluer::Res
let Ok(addr) = addr_str.parse::<Address>() else { return true; };
info!("AirPods connected: {}, initializing", name);
let handle_clone = tray_handle.clone();
let ui_tx_clone = ui_tx.clone();
tokio::spawn(async move {
let _airpods_device = AirPodsDevice::new(addr, handle_clone).await;
ui_tx_clone.send(UIMessage::DeviceConnected(addr_str)).unwrap();
let _airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone).await;
});
true
})?;

View File

@@ -0,0 +1,15 @@
use crate::bluetooth::aacp::{AACPEvent, ControlCommandIdentifiers};
#[derive(Debug, Clone)]
pub enum UIMessage {
OpenWindow,
DeviceConnected(String),
DeviceDisconnected(String),
AACPUIEvent(String, AACPEvent),
NoOp,
}
#[derive(Debug, Clone)]
pub enum UICommand {
SetControlCommandStatus(String, ControlCommandIdentifiers, Vec<u8>),
}

View File

@@ -1,2 +1,3 @@
pub mod tray;
pub mod window;
pub mod window;
pub mod messages;

View File

@@ -5,6 +5,7 @@ use ksni::{Icon, ToolTip};
use tokio::sync::mpsc::UnboundedSender;
use crate::bluetooth::aacp::ControlCommandIdentifiers;
use crate::ui::messages::UIMessage;
#[derive(Debug)]
pub(crate) struct MyTray {
@@ -18,8 +19,8 @@ pub(crate) struct MyTray {
pub(crate) connected: bool,
pub(crate) listening_mode: Option<u8>,
pub(crate) allow_off_option: Option<u8>,
pub(crate) command_tx: Option<tokio::sync::mpsc::UnboundedSender<(ControlCommandIdentifiers, Vec<u8>)>>,
pub(crate) ui_tx: Option<UnboundedSender<()>>,
pub(crate) command_tx: Option<UnboundedSender<(ControlCommandIdentifiers, Vec<u8>)>>,
pub(crate) ui_tx: Option<UnboundedSender<UIMessage>>,
}
impl ksni::Tray for MyTray {
@@ -113,7 +114,7 @@ impl ksni::Tray for MyTray {
icon_name: "window-new".into(),
activate: Box::new(|this: &mut Self| {
if let Some(tx) = &this.ui_tx {
let _ = tx.send(());
let _ = tx.send(UIMessage::OpenWindow);
}
}),
..Default::default()

View File

@@ -1,16 +1,19 @@
use std::collections::HashMap;
use iced::widget::button::Style;
use iced::widget::{button, column, container, pane_grid, text, Space, combo_box, row, text_input};
use iced::{daemon, window, Background, Border, Center, Color, Element, Length, Subscription, Task, Theme};
use iced::{daemon, window, Background, Border, Center, Color, Element, Length, Size, Subscription, Task, Theme};
use std::sync::Arc;
use iced::border::Radius;
use iced::overlay::menu;
use log::{debug, error};
use tokio::sync::{mpsc::UnboundedReceiver, Mutex};
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::Mutex;
use crate::bluetooth::aacp::{DeviceData, DeviceInformation, DeviceType};
use crate::ui::messages::UIMessage;
use crate::utils::{get_devices_path, get_app_settings_path, MyTheme};
pub fn start_ui(ui_rx: UnboundedReceiver<()>, start_minimized: bool) -> iced::Result {
pub fn start_ui(ui_rx: UnboundedReceiver<UIMessage>, start_minimized: bool) -> iced::Result {
daemon(App::title, App::update, App::view)
.subscription(App::subscription)
.theme(App::theme)
@@ -21,9 +24,22 @@ pub struct App {
window: Option<window::Id>,
panes: pane_grid::State<Pane>,
selected_tab: Tab,
ui_rx: Arc<Mutex<UnboundedReceiver<()>>>,
theme_state: combo_box::State<MyTheme>,
selected_theme: MyTheme,
ui_rx: Arc<Mutex<UnboundedReceiver<UIMessage>>>,
bluetooth_state: BluetoothState
}
pub struct BluetoothState {
connected_devices: Vec<String>
}
impl BluetoothState {
pub fn new() -> Self {
Self {
connected_devices: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
@@ -32,9 +48,9 @@ pub enum Message {
WindowClosed(window::Id),
Resized(pane_grid::ResizeEvent),
SelectTab(Tab),
OpenMainWindow,
ThemeSelected(MyTheme),
CopyToClipboard(String),
UIMessage(UIMessage),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -51,21 +67,25 @@ pub enum Pane {
impl App {
pub fn new(ui_rx: UnboundedReceiver<()>, start_minimized: bool) -> (Self, Task<Message>) {
pub fn new(ui_rx: UnboundedReceiver<UIMessage>, start_minimized: bool) -> (Self, Task<Message>) {
let (mut panes, first_pane) = pane_grid::State::new(Pane::Sidebar);
let split = panes.split(pane_grid::Axis::Vertical, first_pane, Pane::Content);
panes.resize(split.unwrap().1, 0.2);
let ui_rx = Arc::new(Mutex::new(ui_rx));
let wait_task = Task::perform(
wait_for_message(Arc::clone(&ui_rx)),
|_| Message::OpenMainWindow,
|msg| msg,
);
let (window, open_task) = if start_minimized {
(None, Task::none())
} else {
let (id, open) = window::open(window::Settings::default());
let mut settings = window::Settings::default();
settings.min_size = Some(Size::new(400.0, 300.0));
settings.icon = window::icon::from_file("../../assets/icon.png").ok();
let (id, open) = window::open(settings);
(Some(id), open.map(Message::WindowOpened))
};
@@ -77,12 +97,13 @@ impl App {
.and_then(|t| serde_json::from_value(t).ok())
.unwrap_or(MyTheme::Dark);
let bluetooth_state = BluetoothState::new();
(
Self {
window,
panes,
selected_tab: Tab::Device("none".to_string()),
ui_rx,
theme_state: combo_box::State::new(vec![
MyTheme::Light,
MyTheme::Dark,
@@ -108,8 +129,10 @@ impl App {
MyTheme::Ferra,
]),
selected_theme,
ui_rx,
bluetooth_state,
},
Task::batch(vec![open_task, wait_task]),
Task::batch(vec![open_task, wait_task])
)
}
@@ -127,11 +150,7 @@ impl App {
if self.window == Some(id) {
self.window = None;
}
let wait_task = Task::perform(
wait_for_message(Arc::clone(&self.ui_rx)),
|_| Message::OpenMainWindow,
);
wait_task
Task::none()
}
Message::Resized(event) => {
self.panes.resize(event.split, event.ratio);
@@ -141,17 +160,6 @@ impl App {
self.selected_tab = tab;
Task::none()
}
Message::OpenMainWindow => {
if let Some(window_id) = self.window {
Task::batch(vec![
window::gain_focus(window_id),
])
} else {
let (new_window_task, open_task) = window::open(window::Settings::default());
self.window = Some(new_window_task);
open_task.map(Message::WindowOpened)
}
}
Message::ThemeSelected(theme) => {
self.selected_theme = theme;
let app_settings_path = get_app_settings_path();
@@ -163,6 +171,86 @@ impl App {
Message::CopyToClipboard(data) => {
iced::clipboard::write(data)
}
Message::UIMessage(ui_message) => {
match ui_message {
UIMessage::NoOp => {
let ui_rx = Arc::clone(&self.ui_rx);
let wait_task = Task::perform(
wait_for_message(ui_rx),
|msg| msg,
);
wait_task
}
UIMessage::OpenWindow => {
let ui_rx = Arc::clone(&self.ui_rx);
let wait_task = Task::perform(
wait_for_message(ui_rx),
|msg| msg,
);
debug!("Opening main window...");
if let Some(window_id) = self.window {
Task::batch(vec![
window::gain_focus(window_id),
wait_task,
])
} else {
let mut settings = window::Settings::default();
settings.min_size = Some(Size::new(400.0, 300.0));
settings.icon = window::icon::from_file("../../assets/icon.png").ok();
let (new_window_task, open_task) = window::open(settings);
self.window = Some(new_window_task);
Task::batch(vec![
open_task.map(Message::WindowOpened),
wait_task,
])
}
}
UIMessage::DeviceConnected(mac) => {
let ui_rx = Arc::clone(&self.ui_rx);
let wait_task = Task::perform(
wait_for_message(ui_rx),
|msg| msg,
);
debug!("Device connected: {}. Adding to connected devices list", mac);
let mut already_connected = false;
for device in &self.bluetooth_state.connected_devices {
if device == &mac {
already_connected = true;
break;
}
}
if !already_connected {
self.bluetooth_state.connected_devices.push(mac.clone());
}
Task::batch(vec![
wait_task,
])
}
UIMessage::DeviceDisconnected(mac) => {
let ui_rx = Arc::clone(&self.ui_rx);
let wait_task = Task::perform(
wait_for_message(ui_rx),
|msg| msg,
);
debug!("Device disconnected: {}", mac);
Task::batch(vec![
wait_task,
])
}
UIMessage::AACPUIEvent(mac, event) => {
let ui_rx = Arc::clone(&self.ui_rx);
let wait_task = Task::perform(
wait_for_message(ui_rx),
|msg| msg,
);
debug!("AACP UI Event for {}: {:?}", mac, event);
Task::batch(vec![
wait_task,
])
}
}
}
}
}
@@ -178,30 +266,35 @@ impl App {
let pane_grid = pane_grid::PaneGrid::new(&self.panes, |_pane_id, pane, _is_maximized| {
match pane {
Pane::Sidebar => {
let create_tab_button = |tab: Tab, label: &str, description: Option<&str>| -> Element<'_, Message> {
let create_tab_button = |tab: Tab, label: &str, description: &str, connected: bool| -> Element<'_, Message> {
let label = label.to_string();
let description = description.map(|d| d.to_string());
let is_selected = self.selected_tab == tab;
let mut col = column![text(label).size(18)];
if let Some(desc) = description {
col = col.push(text(desc).size(12));
}
let col = column![
text(label).size(16),
text(
if connected {
format!("Connected - {}", description)
} else {
format!("{}", description)
}
).size(12)
];
let content = container(col)
.padding(10);
.padding(8);
let style = move |theme: &Theme, _status| {
if is_selected {
let mut style = Style::default()
.with_background(theme.palette().primary);
let mut border = Border::default();
border.color = theme.palette().text;
style.border = border.rounded(20);
style.border = border.rounded(12);
style
} else {
let mut style = Style::default()
.with_background(theme.palette().primary.scale_alpha(0.1));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.1);
style.border = border.rounded(10);
style.border = border.rounded(8);
style.text_color = theme.palette().text;
style
}
@@ -214,6 +307,38 @@ impl App {
.into()
};
let create_settings_button = || -> Element<'_, Message> {
let label = "Settings".to_string();
let is_selected = self.selected_tab == Tab::Settings;
let col = column![text(label).size(16)];
let content = container(col)
.padding(8);
let style = move |theme: &Theme, _status| {
if is_selected {
let mut style = Style::default()
.with_background(theme.palette().primary);
let mut border = Border::default();
border.color = theme.palette().text;
style.border = border.rounded(12);
style
} else {
let mut style = Style::default()
.with_background(theme.palette().primary.scale_alpha(0.1));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.1);
style.border = border.rounded(8);
style.text_color = theme.palette().text;
style
}
};
button(content)
.style(style)
.padding(5)
.on_press(Message::SelectTab(Tab::Settings))
.width(Length::Fill)
.into()
};
let mut devices = column!().spacing(4);
let mut devices_vec: Vec<(String, DeviceData)> = devices_list.clone().into_iter().collect();
devices_vec.sort_by(|a, b| a.1.name.cmp(&b.1.name));
@@ -222,12 +347,13 @@ impl App {
let tab_button = create_tab_button(
Tab::Device(mac.clone()),
&name,
Some(&mac)
&mac,
self.bluetooth_state.connected_devices.contains(&mac)
);
devices = devices.push(tab_button);
}
let settings = create_tab_button(Tab::Settings, "Settings", None);
let settings = create_settings_button();
let content = column![
devices,
@@ -522,8 +648,15 @@ impl App {
}
}
async fn wait_for_message(rx: Arc<Mutex<UnboundedReceiver<()>>>) {
debug!("Waiting for message to open main window...");
let mut guard = rx.lock().await;
let _ = guard.recv().await;
async fn wait_for_message(
ui_rx: Arc<Mutex<UnboundedReceiver<UIMessage>>>,
) -> Message {
let mut rx = ui_rx.lock().await;
match rx.recv().await {
Some(msg) => Message::UIMessage(msg),
None => {
error!("UI message channel closed");
Message::UIMessage(UIMessage::NoOp)
}
}
}