diff --git a/linux-rust/Cargo.lock b/linux-rust/Cargo.lock index 398ac3f..b2374fc 100644 --- a/linux-rust/Cargo.lock +++ b/linux-rust/Cargo.lock @@ -548,6 +548,12 @@ dependencies = [ "syn 2.0.107", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -1822,6 +1828,7 @@ dependencies = [ "iced_renderer", "iced_widget", "iced_winit", + "image 0.24.9", "thiserror", ] @@ -1885,6 +1892,8 @@ dependencies = [ "half", "iced_core", "iced_futures", + "image 0.24.9", + "kamadak-exif", "log", "once_cell", "raw-window-handle", @@ -1996,6 +2005,24 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "exr", + "gif", + "jpeg-decoder", + "num-traits", + "png 0.17.16", + "qoi", + "tiff 0.9.1", +] + [[package]] name = "image" version = "0.25.8" @@ -2015,7 +2042,7 @@ dependencies = [ "ravif", "rayon", "rgb", - "tiff", + "tiff 0.10.3", "zune-core", "zune-jpeg", ] @@ -2039,7 +2066,7 @@ dependencies = [ "ab_glyph", "approx", "getrandom 0.2.16", - "image", + "image 0.25.8", "itertools", "nalgebra", "num", @@ -2170,6 +2197,15 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -2180,6 +2216,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kamadak-exif" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" +dependencies = [ + "mutate_once", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2334,7 +2379,7 @@ dependencies = [ "futures", "hex", "iced", - "image", + "image 0.25.8", "imageproc", "ksni", "libpulse-binding", @@ -2504,6 +2549,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "naga" version = "0.19.2" @@ -4116,6 +4167,17 @@ dependencies = [ "syn 2.0.107", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "tiff" version = "0.10.3" diff --git a/linux-rust/Cargo.toml b/linux-rust/Cargo.toml index 644b6d6..9251ada 100644 --- a/linux-rust/Cargo.toml +++ b/linux-rust/Cargo.toml @@ -11,7 +11,7 @@ uuid = "1.18.1" log = "0.4.28" dbus = "0.9.9" hex = "0.4.3" -iced = {version = "0.13.1", features = ["tokio"]} +iced = { version = "0.13.1", features = ["tokio", "image"] } libpulse-binding = "2.30.1" ksni = "0.3.1" image = "0.25.8" diff --git a/linux-rust/assets/icon.png b/linux-rust/assets/icon.png new file mode 100644 index 0000000..825ac7a Binary files /dev/null and b/linux-rust/assets/icon.png differ diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index 438410d..5e11cc7 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -8,6 +8,8 @@ use tokio::time::{sleep, Instant}; use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json; +use crate::devices::airpods::AirPodsInformation; +use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType}; use crate::utils::get_devices_path; const PSM: u16 = 0x1001; @@ -280,45 +282,11 @@ pub enum AACPEvent { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub enum DeviceType { - AirPods, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LEData { +pub struct AirPodsLEKeys { pub irk: String, pub enc_key: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AirPodsInformation { - pub name: String, - pub model_number: String, - pub manufacturer: String, - pub serial_number: String, - pub version1: String, - pub version2: String, - pub hardware_revision: String, - pub updater_identifier: String, - pub left_serial_number: String, - pub right_serial_number: String, - pub version3: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", content = "data")] -pub enum DeviceInformation { - AirPods(AirPodsInformation), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeviceData { - pub name: String, - pub type_: DeviceType, - pub le: LEData, - pub information: Option, -} - pub struct AACPManagerState { pub sender: Option>>, pub control_command_status_list: Vec, @@ -647,7 +615,7 @@ impl AACPManager { strings.push(s.to_string()); } } - strings.remove(0); // Remove the first empty string as per comment + strings.remove(0); let info = AirPodsInformation { name: strings.get(0).cloned().unwrap_or_default(), model_number: strings.get(1).cloned().unwrap_or_default(), @@ -660,6 +628,10 @@ impl AACPManager { left_serial_number: strings.get(8).cloned().unwrap_or_default(), right_serial_number: strings.get(9).cloned().unwrap_or_default(), version3: strings.get(10).cloned().unwrap_or_default(), + le_keys: AirPodsLEKeys { + irk: "".to_string(), + enc_key: "".to_string(), + }, }; let mut state = self.state.lock().await; if let Some(mac) = state.airpods_mac { @@ -715,12 +687,29 @@ impl AACPManager { let device_data = state.devices.entry(mac_str.clone()).or_insert(DeviceData { name: mac_str.clone(), type_: DeviceType::AirPods, - le: LEData { irk: "".to_string(), enc_key: "".to_string() }, information: None, }); match kt { - ProximityKeyType::Irk => device_data.le.irk = hex::encode(key_data), - ProximityKeyType::EncKey => device_data.le.enc_key = hex::encode(key_data), + ProximityKeyType::Irk => { + match device_data.information.as_mut() { + Some(DeviceInformation::AirPods(info)) => { + info.le_keys.irk = hex::encode(key_data); + } + _ => { + error!("Device information is not AirPods for adding LE IRK."); + } + } + } + ProximityKeyType::EncKey => { + match device_data.information.as_mut() { + Some(DeviceInformation::AirPods(info)) => { + info.le_keys.enc_key = hex::encode(key_data); + } + _ => { + error!("Device information is not AirPods for adding LE encryption key."); + } + } + } } } } diff --git a/linux-rust/src/bluetooth/att.rs b/linux-rust/src/bluetooth/att.rs index c770139..88fd37e 100644 --- a/linux-rust/src/bluetooth/att.rs +++ b/linux-rust/src/bluetooth/att.rs @@ -16,29 +16,34 @@ const OPCODE_READ_REQUEST: u8 = 0x0A; const OPCODE_WRITE_REQUEST: u8 = 0x12; const OPCODE_HANDLE_VALUE_NTF: u8 = 0x1B; const OPCODE_WRITE_RESPONSE: u8 = 0x13; +const RESPONSE_TIMEOUT: u64 = 5000; #[repr(u16)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ATTHandles { - Transparency = 0x18, - LoudSoundReduction = 0x1B, - HearingAid = 0x2A, + AirPodsTransparency = 0x18, + AirPodsLoudSoundReduction = 0x1B, + AirPodsHearingAid = 0x2A, + NothingEverything = 0x8002, + NothingEverythingRead = 0x8005 // for some reason, and not the same as the write handle } #[repr(u16)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ATTCCCDHandles { - Transparency = ATTHandles::Transparency as u16 + 1, - LoudSoundReduction = ATTHandles::LoudSoundReduction as u16 + 1, - HearingAid = ATTHandles::HearingAid as u16 + 1, + Transparency = ATTHandles::AirPodsTransparency as u16 + 1, + LoudSoundReduction = ATTHandles::AirPodsLoudSoundReduction as u16 + 1, + HearingAid = ATTHandles::AirPodsHearingAid as u16 + 1, } impl From for ATTCCCDHandles { fn from(handle: ATTHandles) -> Self { match handle { - ATTHandles::Transparency => ATTCCCDHandles::Transparency, - ATTHandles::LoudSoundReduction => ATTCCCDHandles::LoudSoundReduction, - ATTHandles::HearingAid => ATTCCCDHandles::HearingAid, + ATTHandles::AirPodsTransparency => ATTCCCDHandles::Transparency, + ATTHandles::AirPodsLoudSoundReduction => ATTCCCDHandles::LoudSoundReduction, + ATTHandles::AirPodsHearingAid => ATTCCCDHandles::HearingAid, + ATTHandles::NothingEverything => panic!("No CCCD for NothingEverything handle"), // we don't request it + ATTHandles::NothingEverythingRead => panic!("No CCD for NothingEverythingRead handle") // it sends notifications without CCCD } } } @@ -46,18 +51,13 @@ impl From for ATTCCCDHandles { struct ATTManagerState { sender: Option>>, listeners: HashMap>>>, - responses: mpsc::UnboundedReceiver>, - response_tx: mpsc::UnboundedSender>, } impl ATTManagerState { fn new() -> Self { - let (tx, rx) = mpsc::unbounded_channel(); ATTManagerState { sender: None, - listeners: HashMap::new(), - responses: rx, - response_tx: tx, + listeners: HashMap::new() } } } @@ -65,13 +65,18 @@ impl ATTManagerState { #[derive(Clone)] pub struct ATTManager { state: Arc>, + response_rx: Arc>>>, + response_tx: mpsc::UnboundedSender>, tasks: Arc>>, } impl ATTManager { pub fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); ATTManager { state: Arc::new(Mutex::new(ATTManagerState::new())), + response_rx: Arc::new(Mutex::new(rx)), + response_tx: tx, tasks: Arc::new(Mutex::new(JoinSet::new())), } } @@ -184,11 +189,18 @@ impl ATTManager { } async fn read_response(&self) -> Result> { - let mut state = self.state.lock().await; - match tokio::time::timeout(Duration::from_millis(2000), state.responses.recv()).await { + debug!("Waiting for response..."); + let mut rx = self.response_rx.lock().await; + match tokio::time::timeout(Duration::from_millis(RESPONSE_TIMEOUT), rx.recv()).await { Ok(Some(resp)) => Ok(resp), - Ok(None) => Err(Error::from(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "Response channel closed"))), - Err(_) => Err(Error::from(std::io::Error::new(std::io::ErrorKind::TimedOut, "Response timeout"))), + Ok(None) => Err(Error::from(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "Response channel closed" + ))), + Err(_) => Err(Error::from(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Response timeout" + ))), } } } @@ -217,10 +229,11 @@ async fn recv_thread(manager: ATTManager, sp: Arc) { let _ = listener.send(value.clone()); } } + } else if data[0] == OPCODE_WRITE_RESPONSE { + let _ = manager.response_tx.send(vec![]); } else { // Response - let state = manager.state.lock().await; - let _ = state.response_tx.send(data[1..].to_vec()); + let _ = manager.response_tx.send(data[1..].to_vec()); } } Err(e) => { diff --git a/linux-rust/src/bluetooth/discovery.rs b/linux-rust/src/bluetooth/discovery.rs index 6c56678..b6d0e17 100644 --- a/linux-rust/src/bluetooth/discovery.rs +++ b/linux-rust/src/bluetooth/discovery.rs @@ -1,6 +1,8 @@ use std::io::Error; +use bluer::Adapter; +use log::debug; -pub(crate) async fn find_connected_airpods(adapter: &bluer::Adapter) -> bluer::Result { +pub(crate) async fn find_connected_airpods(adapter: &Adapter) -> bluer::Result { let target_uuid = uuid::Uuid::parse_str("74ec2172-0bad-4d01-8f77-997b2be0722a").unwrap(); let addrs = adapter.device_addresses().await?; @@ -17,4 +19,23 @@ pub(crate) async fn find_connected_airpods(adapter: &bluer::Adapter) -> bluer::R } } Err(bluer::Error::from(Error::new(std::io::ErrorKind::NotFound, "No connected AirPods found"))) +} + +pub async fn find_other_managed_devices(adapter: &Adapter, managed_macs: Vec) -> bluer::Result> { + let addrs = adapter.device_addresses().await?; + let mut devices = Vec::new(); + for addr in addrs { + let device = adapter.device(addr)?; + let device_mac = device.address().to_string(); + let connected = device.is_connected().await.unwrap_or(false); + debug!("Checking device: {}, connected: {}", device_mac, connected); + if connected && managed_macs.contains(&device_mac) { + debug!("Found managed device: {}", device_mac); + devices.push(device); + } + } + if !devices.is_empty() { + return Ok(devices); + } + Err(bluer::Error::from(Error::new(std::io::ErrorKind::NotFound, "No other managed devices found"))) } \ No newline at end of file diff --git a/linux-rust/src/bluetooth/le.rs b/linux-rust/src/bluetooth/le.rs index 0331e2f..7d80c15 100644 --- a/linux-rust/src/bluetooth/le.rs +++ b/linux-rust/src/bluetooth/le.rs @@ -1,4 +1,3 @@ -use std::cmp::PartialEq; use bluer::monitor::{Monitor, MonitorEvent, Pattern}; use bluer::{Address, Session}; use aes::Aes128; @@ -14,7 +13,7 @@ use std::sync::Arc; use tokio::sync::Mutex; use crate::bluetooth::aacp::BatteryStatus; use crate::ui::tray::MyTray; -use crate::bluetooth::aacp::{DeviceData, DeviceType}; +use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType}; use crate::utils::{get_devices_path, get_preferences_path, ah}; fn decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { @@ -43,14 +42,6 @@ fn verify_rpa(addr: &str, irk: &[u8; 16]) -> bool { hash == computed_hash } -impl PartialEq for DeviceType { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (DeviceType::AirPods, DeviceType::AirPods) => true - } - } -} - pub async fn start_le_monitor(tray_handle: Option>) -> bluer::Result<()> { let session = Session::new().await?; let adapter = session.default_adapter().await?; @@ -107,15 +98,17 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue let mut found_mac = None; for (airpods_mac, device_data) in &all_devices { if device_data.type_ == DeviceType::AirPods { - if let Ok(irk_bytes) = hex::decode(&device_data.le.irk) { - if irk_bytes.len() == 16 { - let irk: [u8; 16] = irk_bytes.as_slice().try_into().unwrap(); - debug!("Verifying RPA {} for airpods MAC {} with IRK {}", addr_str, airpods_mac, device_data.le.irk); - if verify_rpa(&addr_str, &irk) { - info!("Matched our device ({}) with the irk for {}", addr, airpods_mac); - verified_macs.insert(addr, airpods_mac.clone()); - found_mac = Some(airpods_mac.clone()); - break; + if let Some(DeviceInformation::AirPods(info)) = &device_data.information { + if let Ok(irk_bytes) = hex::decode(&info.le_keys.irk) { + if irk_bytes.len() == 16 { + let irk: [u8; 16] = irk_bytes.as_slice().try_into().unwrap(); + debug!("Verifying RPA {} for airpods MAC {} with IRK {}", addr_str, airpods_mac, info.le_keys.irk); + if verify_rpa(&addr_str, &irk) { + info!("Matched our device ({}) with the irk for {}", addr, airpods_mac); + verified_macs.insert(addr, airpods_mac.clone()); + found_mac = Some(airpods_mac.clone()); + break; + } } } } @@ -133,8 +126,8 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue if let Some(ref mac) = matched_airpods_mac { if let Some(device_data) = all_devices.get(mac) { - if !device_data.le.enc_key.is_empty() { - if let Ok(enc_key_bytes) = hex::decode(&device_data.le.enc_key) { + if let Some(DeviceInformation::AirPods(info)) = &device_data.information { + if let Ok(enc_key_bytes) = hex::decode(&info.le_keys.enc_key) { if enc_key_bytes.len() == 16 { matched_enc_key = Some(enc_key_bytes.as_slice().try_into().unwrap()); } diff --git a/linux-rust/src/bluetooth/managers.rs b/linux-rust/src/bluetooth/managers.rs new file mode 100644 index 0000000..25cf630 --- /dev/null +++ b/linux-rust/src/bluetooth/managers.rs @@ -0,0 +1,52 @@ +use std::collections::HashMap; +use std::sync::Arc; +use crate::bluetooth::aacp::AACPManager; +use crate::bluetooth::att::ATTManager; + +pub enum BluetoothManager { + AACP(Arc), + ATT(Arc), +} + +pub struct DeviceManagers { + att: Option>, + aacp: Option>, +} + +impl DeviceManagers { + fn new() -> Self { + Self { att: None, aacp: None } + } + + fn with_aacp(aacp: AACPManager) -> Self { + Self { att: None, aacp: Some(Arc::new(aacp)) } + } + + fn with_att(att: ATTManager) -> Self { + Self { att: Some(Arc::new(att)), aacp: None } + } +} + +pub struct BluetoothDevices { + devices: HashMap, +} + +impl BluetoothDevices { + fn new() -> Self { + Self { devices: HashMap::new() } + } + + fn add_aacp(&mut self, mac: String, manager: AACPManager) { + self.devices + .entry(mac) + .or_insert_with(DeviceManagers::new) + .aacp = Some(Arc::new(manager)); + } + + fn add_att(&mut self, mac: String, manager: ATTManager) { + self.devices + .entry(mac) + .or_insert_with(DeviceManagers::new) + .att = Some(Arc::new(manager)); + } +} diff --git a/linux-rust/src/bluetooth/mod.rs b/linux-rust/src/bluetooth/mod.rs index d628d9e..dd4bd55 100644 --- a/linux-rust/src/bluetooth/mod.rs +++ b/linux-rust/src/bluetooth/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod discovery; pub mod aacp; pub mod att; -pub mod le; \ No newline at end of file +pub mod le; +pub mod managers; \ No newline at end of file diff --git a/linux-rust/src/airpods.rs b/linux-rust/src/devices/airpods.rs similarity index 88% rename from linux-rust/src/airpods.rs rename to linux-rust/src/devices/airpods.rs index 21ebed3..71a533a 100644 --- a/linux-rust/src/airpods.rs +++ b/linux-rust/src/devices/airpods.rs @@ -1,4 +1,4 @@ -use crate::bluetooth::aacp::{AACPManager, ProximityKeyType, AACPEvent}; +use crate::bluetooth::aacp::{AACPManager, ProximityKeyType, AACPEvent, AirPodsLEKeys}; use crate::bluetooth::aacp::ControlCommandIdentifiers; // use crate::bluetooth::att::ATTManager; use crate::media_controller::MediaController; @@ -6,20 +6,26 @@ use bluer::Address; use log::{debug, info, error}; use std::sync::Arc; use ksni::Handle; +use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tokio::time::{sleep, Duration}; use crate::ui::tray::MyTray; -use crate::ui::messages::UIMessage; +use crate::ui::messages::BluetoothUIMessage; pub struct AirPodsDevice { pub mac_address: Address, pub aacp_manager: AACPManager, // pub att_manager: ATTManager, pub media_controller: Arc>, + // pub command_tx: Option)>>, } impl AirPodsDevice { - pub async fn new(mac_address: Address, tray_handle: Option>, ui_tx: tokio::sync::mpsc::UnboundedSender) -> Self { + pub async fn new( + mac_address: Address, + tray_handle: Option>, + ui_tx: tokio::sync::mpsc::UnboundedSender, + ) -> Self { info!("Creating new AirPodsDevice for {}", mac_address); let mut aacp_manager = AACPManager::new(); aacp_manager.connect(mac_address).await; @@ -146,8 +152,9 @@ impl AirPodsDevice { let aacp_manager_clone_events = aacp_manager.clone(); let local_mac_events = local_mac.clone(); + let ui_tx_clone = ui_tx.clone(); + let command_tx_clone = command_tx.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 { @@ -182,12 +189,12 @@ impl AirPodsDevice { } debug!("Updated tray with new battery info"); - let _ = ui_tx_clone.send(UIMessage::AACPUIEvent(mac_address.to_string(), event_clone)); + let _ = ui_tx_clone.send(BluetoothUIMessage::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)); + let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(mac_address.to_string(), event_clone)); debug!("Sent ControlCommand event to UI"); } AACPEvent::ConversationalAwareness(status) => { @@ -221,14 +228,14 @@ impl AirPodsDevice { } AACPEvent::OwnershipToFalseRequest => { info!("Received ownership to false request. Setting ownership to false and pausing media."); - let _ = command_tx.send((ControlCommandIdentifiers::OwnsConnection, vec![0x00])); + let _ = command_tx_clone.send((ControlCommandIdentifiers::OwnsConnection, vec![0x00])); let controller = mc_clone.lock().await; 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)); + let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(mac_address.to_string(), event_clone)); debug!("Sent unhandled AACP event to UI"); } } @@ -240,6 +247,23 @@ impl AirPodsDevice { aacp_manager, // att_manager, media_controller, + // command_tx: Some(command_tx.clone()), } } } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AirPodsInformation { + pub name: String, + pub model_number: String, + pub manufacturer: String, + pub serial_number: String, + pub version1: String, + pub version2: String, + pub hardware_revision: String, + pub updater_identifier: String, + pub left_serial_number: String, + pub right_serial_number: String, + pub version3: String, + pub le_keys: AirPodsLEKeys +} \ No newline at end of file diff --git a/linux-rust/src/devices/enums.rs b/linux-rust/src/devices/enums.rs new file mode 100644 index 0000000..846d436 --- /dev/null +++ b/linux-rust/src/devices/enums.rs @@ -0,0 +1,107 @@ +use std::fmt::Display; +use serde::{Deserialize, Serialize}; +use crate::devices::airpods::AirPodsInformation; +use crate::devices::nothing::NothingInformation; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(PartialEq)] +pub enum DeviceType { + AirPods, + Nothing +} + +impl Display for DeviceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeviceType::AirPods => write!(f, "AirPods"), + DeviceType::Nothing => write!(f, "Nothing"), + } + } +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data")] +pub enum DeviceInformation { + AirPods(AirPodsInformation), + Nothing(NothingInformation) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceData { + pub name: String, + pub type_: DeviceType, + pub information: Option, +} + + +#[derive(Clone, Debug)] +pub enum DeviceState { + AirPods(AirPodsState), + Nothing(NothingState), +} + +impl Display for DeviceState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeviceState::AirPods(_) => write!(f, "AirPods State"), + DeviceState::Nothing(_) => write!(f, "Nothing State"), + } + } +} + +#[derive(Clone, Debug)] +pub struct AirPodsState { + pub conversation_awareness_enabled: bool, +} + +#[derive(Clone, Debug)] +pub struct NothingState { + pub anc_mode: NothingAncMode, +} + +#[derive(Clone, Debug)] +pub enum NothingAncMode { + Off, + LowNoiseCancellation, + MidNoiseCancellation, + HighNoiseCancellation, + AdaptiveNoiseCancellation, + Transparency +} + +impl Display for NothingAncMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NothingAncMode::Off => write!(f, "Off"), + NothingAncMode::LowNoiseCancellation => write!(f, "Low Noise Cancellation"), + NothingAncMode::MidNoiseCancellation => write!(f, "Mid Noise Cancellation"), + NothingAncMode::HighNoiseCancellation => write!(f, "High Noise Cancellation"), + NothingAncMode::AdaptiveNoiseCancellation => write!(f, "Adaptive Noise Cancellation"), + NothingAncMode::Transparency => write!(f, "Transparency"), + } + } +} +impl NothingAncMode { + pub fn from_byte(value: u8) -> Self { + match value { + 0x03 => NothingAncMode::LowNoiseCancellation, + 0x02 => NothingAncMode::MidNoiseCancellation, + 0x01 => NothingAncMode::HighNoiseCancellation, + 0x04 => NothingAncMode::AdaptiveNoiseCancellation, + 0x07 => NothingAncMode::Transparency, + 0x05 => NothingAncMode::Off, + _ => NothingAncMode::Off, + } + } + pub fn to_byte(&self) -> u8 { + match self { + NothingAncMode::LowNoiseCancellation => 0x03, + NothingAncMode::MidNoiseCancellation => 0x02, + NothingAncMode::HighNoiseCancellation => 0x01, + NothingAncMode::AdaptiveNoiseCancellation => 0x04, + NothingAncMode::Transparency => 0x07, + NothingAncMode::Off => 0x05, + } + } +} \ No newline at end of file diff --git a/linux-rust/src/devices/mod.rs b/linux-rust/src/devices/mod.rs new file mode 100644 index 0000000..d40c1cf --- /dev/null +++ b/linux-rust/src/devices/mod.rs @@ -0,0 +1,3 @@ +pub mod airpods; +pub mod enums; +pub(crate) mod nothing; \ No newline at end of file diff --git a/linux-rust/src/devices/nothing.rs b/linux-rust/src/devices/nothing.rs new file mode 100644 index 0000000..1246270 --- /dev/null +++ b/linux-rust/src/devices/nothing.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; +use std::time::Duration; +use bluer::Address; +use log::{debug, info}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; +use tokio::time::sleep; +use crate::bluetooth::att::{ATTHandles, ATTManager}; +use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType}; +use crate::ui::messages::BluetoothUIMessage; +use crate::utils::get_devices_path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NothingInformation{ + pub serial_number: String, + pub firmware_version: String +} + +pub struct NothingDevice{ + pub att_manager: ATTManager, + pub information: NothingInformation +} + +impl NothingDevice{ + pub async fn new( + mac_address: Address, + ui_tx: mpsc::UnboundedSender + ) -> Self { + let mut att_manager = ATTManager::new(); + att_manager.connect(mac_address).await.expect("Failed to connect"); + + let (tx, mut rx) = mpsc::unbounded_channel::>(); + + att_manager.register_listener( + ATTHandles::NothingEverythingRead, + tx + ).await; + + let devices: HashMap = + std::fs::read_to_string(get_devices_path()) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let device_key = mac_address.to_string(); + let information = if let Some(device_data) = devices.get(&device_key) { + let info = device_data.information.clone(); + if let Some(DeviceInformation::Nothing(ref nothing_info)) = info { + nothing_info.clone() + } else { + NothingInformation{ + serial_number: String::new(), + firmware_version: String::new() + } + } + } else { + NothingInformation{ + serial_number: String::new(), + firmware_version: String::new() + } + }; + + // Request version information + att_manager.write( + ATTHandles::NothingEverything, + &[ + 0x55, 0x20, + 0x01, 0x42, + 0xC0, 0x00, + 0x00, 0x00, + 0x00, 0x00 // something, idk + ] + ).await.expect("Failed to write"); + + sleep(Duration::from_millis(100)).await; + + // Request serial number + att_manager.write( + ATTHandles::NothingEverything, + &[ + 0x55, 0x20, + 0x01, 0x06, + 0xC0, 0x00, + 0x00, 0x13, + 0x00, 0x00 + ] + ).await.expect("Failed to write"); + + // let ui_tx_clone = ui_tx.clone(); + let information_l = information.clone(); + tokio::spawn(async move { + while let Some(data) = rx.recv().await { + if data.starts_with(&[ + 0x55, 0x20, + 0x01, 0x42, 0x40 + ]) { + let firmware_version = String::from_utf8_lossy(&data[8..]).to_string(); + info!("Received firmware version from Nothing device {}: {}", mac_address, firmware_version); + let new_information = NothingInformation{ + serial_number: information_l.serial_number.clone(), + firmware_version: firmware_version.clone() + }; + let mut new_devices = devices.clone(); + new_devices.insert( + device_key.clone(), + DeviceData{ + name: devices.get(&device_key) + .map(|d| d.name.clone()) + .unwrap_or("Nothing Device".to_string()), + type_: devices.get(&device_key) + .map(|d| d.type_.clone()) + .unwrap_or(DeviceType::Nothing), + information: Some(DeviceInformation::Nothing(new_information)), + } + ); + let json = serde_json::to_string(&new_devices).unwrap(); + std::fs::write(get_devices_path(), json).expect("Failed to write devices file"); + } else if data.starts_with( + &[ + 0x55, 0x20, + 0x01, 0x06, 0x40 + ] + ) { + let serial_number_start_position = data.iter().position(|&b| b == "S".as_bytes()[0]).unwrap_or(8); + let serial_number_end = data.iter() + .skip(serial_number_start_position) + .position(|&b| b == 0x0A) + .map(|pos| pos + serial_number_start_position) + .unwrap_or(data.len()); + if data.get(serial_number_start_position + 1) == Some(&"H".as_bytes()[0]) { + let serial_number = String::from_utf8_lossy( + &data[serial_number_start_position..serial_number_end] + ).to_string(); + info!("Received serial number from Nothing device {}: {}", mac_address, serial_number); + let new_information = NothingInformation{ + serial_number: serial_number.clone(), + firmware_version: information_l.firmware_version.clone() + }; + let mut new_devices = devices.clone(); + new_devices.insert( + device_key.clone(), + DeviceData{ + name: devices.get(&device_key) + .map(|d| d.name.clone()) + .unwrap_or("Nothing Device".to_string()), + type_: devices.get(&device_key) + .map(|d| d.type_.clone()) + .unwrap_or(DeviceType::Nothing), + information: Some(DeviceInformation::Nothing(new_information)), + } + ); + let json = serde_json::to_string(&new_devices).unwrap(); + std::fs::write(get_devices_path(), json).expect("Failed to write devices file"); + } else { + debug!("Serial number format unexpected from Nothing device {}: {:?}", mac_address, data); + } + } + else {} + debug!("Received data from (Nothing) device {}, data: {:?}", mac_address, data); + } + }); + + NothingDevice{ + att_manager, + information + } + } +} \ No newline at end of file diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index 9be27ce..a2af5c1 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -1,8 +1,8 @@ mod bluetooth; -mod airpods; mod media_controller; mod ui; mod utils; +mod devices; use std::env; use log::info; @@ -11,15 +11,20 @@ use dbus::blocking::stdintf::org_freedesktop_dbus::Properties; use dbus::message::MatchRule; use dbus::arg::{RefArg, Variant}; use std::collections::HashMap; -use crate::bluetooth::discovery::find_connected_airpods; -use crate::airpods::AirPodsDevice; +use std::sync::Arc; +use crate::bluetooth::discovery::{find_connected_airpods, find_other_managed_devices}; +use devices::airpods::AirPodsDevice; use bluer::Address; use ksni::TrayMethods; 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; +use crate::bluetooth::att::ATTHandles; +use crate::bluetooth::managers::BluetoothManager; +use crate::devices::enums::DeviceData; +use crate::ui::messages::{AirPodsCommand, BluetoothUIMessage, NothingCommand, UICommand}; +use crate::utils::get_devices_path; #[derive(Parser)] struct Args { @@ -35,23 +40,48 @@ 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.to_owned() + ",wgpu_core=off,librepods_rust::bluetooth::le=off,cosmic_text=off,naga=off,iced_winit=off") }; + unsafe { env::set_var("RUST_LOG", log_level.to_owned() + ",iced_wgpu=off,wgpu_hal=off,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::(); + let (ui_command_tx, ui_command_rx) = unbounded_channel::(); + std::thread::spawn(|| { let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async_main(ui_tx)).unwrap(); + rt.block_on(async_main(ui_tx, ui_command_rx)).unwrap(); }); - ui::window::start_ui(ui_rx, args.start_minimized) + ui::window::start_ui(ui_rx, args.start_minimized, ui_command_tx) } -async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender) -> bluer::Result<()> { +async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender, mut ui_command_rx: tokio::sync::mpsc::UnboundedReceiver) -> bluer::Result<()> { let args = Args::parse(); + // let mut device_command_txs: HashMap)>> = HashMap::new(); + let mut device_managers: HashMap> = HashMap::new(); + + let mut managed_devices_mac: Vec = Vec::new(); // includes ony non-AirPods. AirPods handled separately. + + let devices_path = get_devices_path(); + let devices_json = std::fs::read_to_string(&devices_path).unwrap_or_else(|e| { + log::error!("Failed to read devices file: {}", e); + "{}".to_string() + }); + let devices_list: HashMap = serde_json::from_str(&devices_json).unwrap_or_else(|e| { + log::error!("Deserialization failed: {}", e); + HashMap::new() + }); + for (mac, device_data) in devices_list.iter() { + match device_data.type_ { + devices::enums::DeviceType::Nothing => { + managed_devices_mac.push(mac.clone()); + } + _ => {} + } + } + let tray_handle = if args.no_tray { None } else { @@ -93,16 +123,51 @@ async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender) -> blu let name = device.name().await?.unwrap_or_else(|| "Unknown".to_string()); info!("Found connected AirPods: {}, initializing.", name); 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; + ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(device.address().to_string())).unwrap(); + let airpods_device = AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx_clone).await; + // device_command_txs.insert(device.address().to_string(), airpods_device.command_tx.unwrap()); + // device_managers.insert(device.address().to_string(), Arc::new(airpods_device.aacp_manager)); + device_managers.insert( + device.address().to_string(), + Arc::from(BluetoothManager::AACP(Arc::new(airpods_device.aacp_manager))), + ); } Err(_) => { info!("No connected AirPods found."); } } + match find_other_managed_devices(&adapter, managed_devices_mac.clone()).await { + Ok(devices) => { + for device in devices { + let addr_str = device.address().to_string(); + info!("Found connected managed device: {}, initializing.", addr_str); + let type_ = devices_list.get(&addr_str).unwrap().type_.clone(); + let ui_tx_clone = ui_tx.clone(); + let mut device_managers = device_managers.clone(); + tokio::spawn(async move { + ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())).unwrap(); + match type_ { + devices::enums::DeviceType::Nothing => { + let dev = devices::nothing::NothingDevice::new(device.address(), ui_tx_clone).await; + device_managers.insert( + addr_str, + Arc::from(BluetoothManager::ATT(Arc::new(dev.att_manager))), + ); + } + _ => {} + } + }); + } + } + Err(e) => { + log::error!("Error finding connected managed devices: {}", e); + } + } + let conn = Connection::new_system()?; let rule = MatchRule::new_signal("org.freedesktop.DBus.Properties", "PropertiesChanged"); + let device_managers_clone = device_managers.clone(); conn.add_match(rule, move |_: (), conn, msg| { let Some(path) = msg.path() else { return true; }; if !path.contains("/org/bluez/hci") || !path.contains("/dev_") { @@ -123,21 +188,117 @@ async fn async_main(ui_tx: tokio::sync::mpsc::UnboundedSender) -> blu let proxy = conn.with_proxy("org.bluez", path, std::time::Duration::from_millis(5000)); let Ok(uuids) = proxy.get::>("org.bluez.Device1", "UUIDs") else { return true; }; let target_uuid = "74ec2172-0bad-4d01-8f77-997b2be0722a"; + + let Ok(addr_str) = proxy.get::("org.bluez.Device1", "Address") else { return true; }; + let Ok(addr) = addr_str.parse::
() else { return true; }; + + if managed_devices_mac.contains(&addr_str) { + info!("Managed device connected: {}, initializing", addr_str); + let type_ = devices_list.get(&addr_str).unwrap().type_.clone(); + match type_ { + devices::enums::DeviceType::Nothing => { + let ui_tx_clone = ui_tx.clone(); + let mut device_managers = device_managers.clone(); + tokio::spawn(async move { + ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())).unwrap(); + let dev = devices::nothing::NothingDevice::new(addr, ui_tx_clone).await; + device_managers.insert( + addr_str, + Arc::from(BluetoothManager::ATT(Arc::new(dev.att_manager))), + ); + }); + } + _ => {} + } + return true; + } + if !uuids.iter().any(|u| u.to_lowercase() == target_uuid) { return true; } let name = proxy.get::("org.bluez.Device1", "Name").unwrap_or_else(|_| "Unknown".to_string()); - let Ok(addr_str) = proxy.get::("org.bluez.Device1", "Address") else { return true; }; - let Ok(addr) = addr_str.parse::
() else { return true; }; info!("AirPods connected: {}, initializing", name); let handle_clone = tray_handle.clone(); let ui_tx_clone = ui_tx.clone(); + let mut device_managers = device_managers.clone(); tokio::spawn(async move { - ui_tx_clone.send(UIMessage::DeviceConnected(addr_str)).unwrap(); - let _airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone).await; + ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())).unwrap(); + let airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone).await; + device_managers.insert( + addr_str, + Arc::from(BluetoothManager::AACP(Arc::new(airpods_device.aacp_manager))), + ); }); true })?; + tokio::spawn(async move { + while let Some(command) = ui_command_rx.recv().await { + match command { + UICommand::AirPods(AirPodsCommand::SetControlCommandStatus(mac, identifier, value)) => { + if let Some(manager) = device_managers_clone.get(&mac) { + match manager.as_ref() { + BluetoothManager::AACP(manager) => { + log::debug!("Sending control command to device {}: {:?} = {:?}", mac, identifier, value); + if let Err(e) = manager.send_control_command(identifier, value.as_ref()).await { + log::error!("Failed to send control command to device {}: {}", mac, e); + } + } + _ => { + log::warn!("AACP not available for {}", mac); + } + } + } else { + log::warn!("No manager for device {}", mac); + } + } + UICommand::AirPods(AirPodsCommand::RenameDevice(mac, new_name)) => { + if let Some(manager) = device_managers_clone.get(&mac) { + match manager.as_ref() { + BluetoothManager::AACP(manager) => { + log::debug!("Renaming device {} to {}", mac, new_name); + if let Err(e) = manager.send_rename_packet(&new_name).await { + log::error!("Failed to rename device {}: {}", mac, e); + } + } + _ => { + log::warn!("AACP not available for {}", mac); + } + } + } else { + log::warn!("No manager for device {}", mac); + } + } + UICommand::Nothing(NothingCommand::SetNoiseCancellationMode(mac, mode)) => { + if let Some(manager) = device_managers_clone.get(&mac) { + match manager.as_ref() { + BluetoothManager::ATT(manager) => { + log::debug!("Setting noise cancellation mode for device {}: {:?}", mac, mode); + if let Err(e) = manager.write( + ATTHandles::NothingEverything, + &[ + 0x55, + 0x60, 0x01, + 0x0F, 0xF0, + 0x03, 0x00, + 0x00, 0x01, // the 0x00 is an incremental counter, but it works without it + mode.to_byte(), 0x00, + 0x00, 0x00 // these both bytes were something random, 0 works too + ] + ).await { + log::error!("Failed to set noise cancellation mode for device {}: {}", mac, e); + } + } + _ => { + log::warn!("Nothing manager not available for {}", mac); + } + } + } else { + log::warn!("No manager for device {}", mac); + } + } + } + } + }); info!("Listening for Bluetooth connections via D-Bus..."); loop { diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs new file mode 100644 index 0000000..ed45660 --- /dev/null +++ b/linux-rust/src/ui/airpods.rs @@ -0,0 +1,197 @@ +use std::collections::HashMap; +use iced::widget::{button, column, container, row, text, toggler, Space}; +use iced::{Background, Border, Color, Length, Theme}; +use iced::widget::button::Style; +use log::error; +use crate::devices::enums::{AirPodsState, DeviceData, DeviceInformation}; +use crate::ui::window::{DeviceMessage, Message}; + +pub fn airpods_view<'a>( + mac: &str, + devices_list: &HashMap, + state: &AirPodsState, +) -> iced::widget::Container<'a, Message> { + let mut information_col = column![]; + let mac = mac.to_string(); + if let Some(device) = devices_list.get(mac.as_str()) { + if let Some(DeviceInformation::AirPods(ref airpods_info)) = device.information { + information_col = information_col + .push(text("Device Information").size(18).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + } + )) + .push(Space::with_height(Length::from(10))) + .push( + row![ + text("Model Number").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + text(airpods_info.model_number.clone()).size(16) + ] + ) + .push( + row![ + text("Manufacturer").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + text(airpods_info.manufacturer.clone()).size(16) + ] + ) + .push( + row![ + text("Serial Number").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + button( + text(airpods_info.serial_number.clone()).size(16) + ) + .style( + |theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + } + ) + .padding(0) + .on_press(Message::CopyToClipboard(airpods_info.serial_number.clone())) + ] + ) + .push( + row![ + text("Left Serial Number").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + button( + text(airpods_info.left_serial_number.clone()).size(16) + ) + .style( + |theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + } + ) + .padding(0) + .on_press(Message::CopyToClipboard(airpods_info.left_serial_number.clone())) + ] + ) + .push( + row![ + text("Right Serial Number").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + button( + text(airpods_info.right_serial_number.clone()).size(16) + ) + .style( + |theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + } + ) + .padding(0) + .on_press(Message::CopyToClipboard(airpods_info.right_serial_number.clone())) + ] + ) + .push( + row![ + text("Version 1").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + text(airpods_info.version1.clone()).size(16) + ] + ) + .push( + row![ + text("Version 2").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + text(airpods_info.version2.clone()).size(16) + ] + ) + .push( + row![ + text("Version 3").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + text(airpods_info.version3.clone()).size(16) + ] + ); + } else { + error!("Expected AirPodsInformation for device {}, got something else", mac); + } + } + + let toggler_widget = toggler(state.conversation_awareness_enabled) + .label("Conversation Awareness") + .on_toggle(move |is_enabled| Message::DeviceMessage(mac.to_string(), DeviceMessage::ConversationAwarenessToggled(is_enabled))); + + container( + column![ + toggler_widget, + Space::with_height(Length::from(10)), + container(information_col) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().text; + style.border = border.rounded(20); + style + } + ) + .padding(20) + ] + ) + .padding(20) + .center_x(Length::Fill) + .height(Length::Fill) +} diff --git a/linux-rust/src/ui/messages.rs b/linux-rust/src/ui/messages.rs index 04e777e..8bb8689 100644 --- a/linux-rust/src/ui/messages.rs +++ b/linux-rust/src/ui/messages.rs @@ -1,15 +1,30 @@ use crate::bluetooth::aacp::{AACPEvent, ControlCommandIdentifiers}; +use crate::devices::enums::NothingAncMode; #[derive(Debug, Clone)] -pub enum UIMessage { +pub enum BluetoothUIMessage { OpenWindow, - DeviceConnected(String), - DeviceDisconnected(String), - AACPUIEvent(String, AACPEvent), - NoOp, + DeviceConnected(String), // mac + DeviceDisconnected(String), // mac + AACPUIEvent(String, AACPEvent), // mac, event + ATTNotification(String, u16, Vec), // mac, handle, data + NoOp } #[derive(Debug, Clone)] pub enum UICommand { + AirPods(AirPodsCommand), + Nothing(NothingCommand), +} + +#[derive(Debug, Clone)] +pub enum AirPodsCommand { SetControlCommandStatus(String, ControlCommandIdentifiers, Vec), -} \ No newline at end of file + RenameDevice(String, String), +} + +#[derive(Debug, Clone)] +pub enum NothingCommand { + SetNoiseCancellationMode(String, NothingAncMode), +} + diff --git a/linux-rust/src/ui/mod.rs b/linux-rust/src/ui/mod.rs index feca689..1f22d99 100644 --- a/linux-rust/src/ui/mod.rs +++ b/linux-rust/src/ui/mod.rs @@ -1,3 +1,5 @@ pub mod tray; pub mod window; -pub mod messages; \ No newline at end of file +pub mod messages; +mod airpods; +mod nothing; \ No newline at end of file diff --git a/linux-rust/src/ui/nothing.rs b/linux-rust/src/ui/nothing.rs new file mode 100644 index 0000000..6237247 --- /dev/null +++ b/linux-rust/src/ui/nothing.rs @@ -0,0 +1,77 @@ +use std::collections::HashMap; +use iced::{Background, Border, Length, Theme}; +use iced::widget::{container, text, column, row, Space, combo_box}; +use crate::devices::enums::{DeviceData, DeviceInformation, NothingState}; +use crate::ui::window::Message; + +pub fn nothing_view<'a>( + mac: &str, + devices_list: &HashMap, + state: &NothingState +) -> iced::widget::Container<'a, Message> { + let mut information_col = iced::widget::column![]; + let mac = mac.to_string(); + if let Some(device) = devices_list.get(mac.as_str()) { + if let Some(DeviceInformation::Nothing(ref nothing_info)) = device.information { + information_col = information_col + .push(text("Device Information").size(18).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + } + )) + .push(iced::widget::Space::with_height(iced::Length::from(10))) + .push( + iced::widget::row![ + text("Serial Number").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + iced::widget::Space::with_width(iced::Length::Fill), + text(nothing_info.serial_number.clone()).size(16) + ] + ) + .push( + iced::widget::row![ + text("Firmware Version").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + iced::widget::Space::with_width(iced::Length::Fill), + text(nothing_info.firmware_version.clone()).size(16) + ] + ); + } + } + container( + column![ + row![ + text("Noise Control Mode").size(18), + Space::with_width(Length::Fill), + // combobox here + ], + container(information_col) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().text; + style.border = border.rounded(20); + style + } + ) + .padding(20) + ] + ) + .padding(20) + .center_x(Length::Fill) + .height(Length::Fill) +} \ No newline at end of file diff --git a/linux-rust/src/ui/tray.rs b/linux-rust/src/ui/tray.rs index d0aa875..0dc45aa 100644 --- a/linux-rust/src/ui/tray.rs +++ b/linux-rust/src/ui/tray.rs @@ -5,7 +5,7 @@ use ksni::{Icon, ToolTip}; use tokio::sync::mpsc::UnboundedSender; use crate::bluetooth::aacp::ControlCommandIdentifiers; -use crate::ui::messages::UIMessage; +use crate::ui::messages::BluetoothUIMessage; #[derive(Debug)] pub(crate) struct MyTray { @@ -20,7 +20,7 @@ pub(crate) struct MyTray { pub(crate) listening_mode: Option, pub(crate) allow_off_option: Option, pub(crate) command_tx: Option)>>, - pub(crate) ui_tx: Option>, + pub(crate) ui_tx: Option>, } impl ksni::Tray for MyTray { @@ -114,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(UIMessage::OpenWindow); + let _ = tx.send(BluetoothUIMessage::OpenWindow); } }), ..Default::default() diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 3bbd1a0..b0cc6b8 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -1,23 +1,30 @@ - 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::widget::{button, column, container, pane_grid, text, Space, combo_box, row, text_input, scrollable}; use iced::{daemon, window, Background, Border, Center, Color, Element, Length, Size, Subscription, Task, Theme}; use std::sync::Arc; +use bluer::{Address, Session}; use iced::border::Radius; use iced::overlay::menu; use log::{debug, error}; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::Mutex; -use crate::bluetooth::aacp::{DeviceData, DeviceInformation, DeviceType}; -use crate::ui::messages::UIMessage; +use crate::bluetooth::aacp::{AACPEvent}; +use crate::devices::enums::{AirPodsState, DeviceData, DeviceState, DeviceType, NothingAncMode, NothingState}; +use crate::ui::messages::{AirPodsCommand, BluetoothUIMessage, NothingCommand, UICommand}; use crate::utils::{get_devices_path, get_app_settings_path, MyTheme}; +use crate::ui::airpods::airpods_view; +use crate::ui::nothing::nothing_view; -pub fn start_ui(ui_rx: UnboundedReceiver, start_minimized: bool) -> iced::Result { +pub fn start_ui( + ui_rx: UnboundedReceiver, + start_minimized: bool, + ui_command_tx: tokio::sync::mpsc::UnboundedSender, +) -> iced::Result { daemon(App::title, App::update, App::view) .subscription(App::subscription) .theme(App::theme) - .run_with(move || App::new(ui_rx, start_minimized)) + .run_with(move || App::new(ui_rx, start_minimized, ui_command_tx)) } pub struct App { @@ -26,8 +33,14 @@ pub struct App { selected_tab: Tab, theme_state: combo_box::State, selected_theme: MyTheme, - ui_rx: Arc>>, - bluetooth_state: BluetoothState + ui_rx: Arc>>, + bluetooth_state: BluetoothState, + ui_command_tx: tokio::sync::mpsc::UnboundedSender, + paired_devices: HashMap, + device_states: HashMap, + pending_add_device: Option<(String, Address)>, + device_type_state: combo_box::State, + selected_device_type: Option, } pub struct BluetoothState { @@ -42,6 +55,12 @@ impl BluetoothState { } } +#[derive(Debug, Clone)] +pub enum DeviceMessage { + ConversationAwarenessToggled(bool), + NothingAncModeSelected(NothingAncMode) +} + #[derive(Debug, Clone)] pub enum Message { WindowOpened(window::Id), @@ -50,13 +69,21 @@ pub enum Message { SelectTab(Tab), ThemeSelected(MyTheme), CopyToClipboard(String), - UIMessage(UIMessage), + BluetoothMessage(BluetoothUIMessage), + DeviceMessage(String, DeviceMessage), + ShowNewDialogTab, + GotPairedDevices(HashMap), + StartAddDevice(String, Address), + SelectDeviceType(DeviceType), + ConfirmAddDevice, + CancelAddDevice, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Tab { Device(String), Settings, + AddDevice } #[derive(Clone, Copy)] @@ -65,9 +92,12 @@ pub enum Pane { Content, } - impl App { - pub fn new(ui_rx: UnboundedReceiver, start_minimized: bool) -> (Self, Task) { + pub fn new( + ui_rx: UnboundedReceiver, + start_minimized: bool, + ui_command_tx: tokio::sync::mpsc::UnboundedSender, + ) -> (Self, Task) { 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); @@ -99,6 +129,14 @@ impl App { let bluetooth_state = BluetoothState::new(); + // let dummy_device_state = DeviceState::AirPods(AirPodsState { + // conversation_awareness_enabled: false, + // }); + // let device_states = HashMap::from([ + // ("28:2D:7F:C2:05:5B".to_string(), dummy_device_state), + // ]); + + let device_states = HashMap::new(); ( Self { window, @@ -131,6 +169,14 @@ impl App { selected_theme, ui_rx, bluetooth_state, + ui_command_tx, + paired_devices: HashMap::new(), + device_states, + pending_add_device: None, + device_type_state: combo_box::State::new(vec![ + DeviceType::Nothing + ]), + selected_device_type: None, }, Task::batch(vec![open_task, wait_task]) ) @@ -171,9 +217,35 @@ impl App { Message::CopyToClipboard(data) => { iced::clipboard::write(data) } - Message::UIMessage(ui_message) => { + Message::DeviceMessage(mac, device_msg) => { + match device_msg { + DeviceMessage::ConversationAwarenessToggled(is_enabled) => { + if let Some(DeviceState::AirPods(state)) = self.device_states.get_mut(&mac) { + state.conversation_awareness_enabled = is_enabled; + let value = if is_enabled { 0x01 } else { 0x02 }; + let _ = self.ui_command_tx.send(UICommand::AirPods(AirPodsCommand::SetControlCommandStatus( + mac, + crate::bluetooth::aacp::ControlCommandIdentifiers::ConversationDetectConfig, + vec![value], + ))); + } + Task::none() + } + DeviceMessage::NothingAncModeSelected(mode) => { + if let Some(DeviceState::Nothing(state)) = self.device_states.get_mut(&mac) { + state.anc_mode = mode.clone(); + let _ = self.ui_command_tx.send(UICommand::Nothing(NothingCommand::SetNoiseCancellationMode( + mac, + mode, + ))); + } + Task::none() + } + } + } + Message::BluetoothMessage(ui_message) => { match ui_message { - UIMessage::NoOp => { + BluetoothUIMessage::NoOp => { let ui_rx = Arc::clone(&self.ui_rx); let wait_task = Task::perform( wait_for_message(ui_rx), @@ -181,7 +253,7 @@ impl App { ); wait_task } - UIMessage::OpenWindow => { + BluetoothUIMessage::OpenWindow => { let ui_rx = Arc::clone(&self.ui_rx); let wait_task = Task::perform( wait_for_message(ui_rx), @@ -205,7 +277,7 @@ impl App { ]) } } - UIMessage::DeviceConnected(mac) => { + BluetoothUIMessage::DeviceConnected(mac) => { let ui_rx = Arc::clone(&self.ui_rx); let wait_task = Task::perform( wait_for_message(ui_rx), @@ -223,34 +295,151 @@ impl App { self.bluetooth_state.connected_devices.push(mac.clone()); } + // self.device_states.insert(mac.clone(), DeviceState::AirPods(AirPodsState { + // conversation_awareness_enabled: false, + // })); + + let type_ = { + let devices_json = std::fs::read_to_string(get_devices_path()).unwrap_or_else(|e| { + error!("Failed to read devices file: {}", e); + "{}".to_string() + }); + let devices_list: HashMap = serde_json::from_str(&devices_json).unwrap_or_else(|e| { + error!("Deserialization failed: {}", e); + HashMap::new() + }); + devices_list.get(&mac).map(|d| d.type_.clone()) + }; + match type_ { + Some(DeviceType::AirPods) => { + self.device_states.insert(mac.clone(), DeviceState::AirPods(AirPodsState { + conversation_awareness_enabled: false, + })); + } + Some(DeviceType::Nothing) => { + self.device_states.insert(mac.clone(), DeviceState::Nothing(NothingState { + anc_mode: NothingAncMode::Off, + })); + } + _ => {} + } + Task::batch(vec![ wait_task, ]) } - UIMessage::DeviceDisconnected(mac) => { + BluetoothUIMessage::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); + + self.device_states.remove(&mac); Task::batch(vec![ wait_task, ]) } - UIMessage::AACPUIEvent(mac, event) => { + BluetoothUIMessage::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); + match event { + AACPEvent::ControlCommand(status) => { + match status.identifier { + crate::bluetooth::aacp::ControlCommandIdentifiers::ConversationDetectConfig => { + let is_enabled = match status.value.as_slice() { + [0x01] => true, + [0x02] => false, + _ => { + error!("Unknown Conversation Detect Config value: {:?}", status.value); + false + } + }; + if let Some(DeviceState::AirPods(state)) = self.device_states.get_mut(&mac) { + state.conversation_awareness_enabled = is_enabled; + } + } + _ => { + debug!("Unhandled Control Command Status: {:?}", status); + } + } + } + _ => {} + } + Task::batch(vec![ + wait_task, + ]) + } + BluetoothUIMessage::ATTNotification(mac, handle, value) => { + debug!("ATT Notification for {}: handle=0x{:04X}, value={:?}", mac, handle, value); + let ui_rx = Arc::clone(&self.ui_rx); + let wait_task = Task::perform( + wait_for_message(ui_rx), + |msg| msg, + ); Task::batch(vec![ wait_task, ]) } } } + Message::ShowNewDialogTab => { + debug!("switching to Add Device tab"); + self.selected_tab = Tab::AddDevice; + Task::perform(load_paired_devices(), Message::GotPairedDevices) + } + Message::GotPairedDevices(map) => { + self.paired_devices = map; + Task::none() + } + Message::StartAddDevice(name, addr) => { + self.pending_add_device = Some((name, addr)); + self.selected_device_type = None; + Task::none() + } + Message::SelectDeviceType(device_type) => { + self.selected_device_type = Some(device_type); + Task::none() + } + Message::ConfirmAddDevice => { + if let Some((name, addr)) = self.pending_add_device.take() { + if let Some(type_) = self.selected_device_type.take() { + let devices_path = get_devices_path(); + let devices_json = std::fs::read_to_string(&devices_path).unwrap_or_else(|e| { + error!("Failed to read devices file: {}", e); + "{}".to_string() + }); + let mut devices_list: HashMap = serde_json::from_str(&devices_json).unwrap_or_else(|e| { + error!("Deserialization failed: {}", e); + HashMap::new() + }); + devices_list.insert(addr.to_string(), DeviceData { + name, + type_: type_.clone(), + information: None + }); + let updated_json = serde_json::to_string(&devices_list).unwrap_or_else(|e| { + error!("Serialization failed: {}", e); + "{}".to_string() + }); + if let Err(e) = std::fs::write(&devices_path, updated_json) { + error!("Failed to write devices file: {}", e); + } + self.selected_tab = Tab::Device(addr.to_string()); + } + } + Task::none() + } + Message::CancelAddDevice => { + self.pending_add_device = None; + self.selected_device_type = None; + Task::none() + } } } @@ -356,6 +545,33 @@ impl App { let settings = create_settings_button(); let content = column![ + row![ + text("Devices").size(18), + Space::with_width(Length::Fill), + button( + container(text("+").size(18)).center_x(Length::Fill).center_y(Length::Fill) + ) + .style( + |theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + style.border = Border { + width: 1.0, + color: theme.palette().primary.scale_alpha(0.1), + radius: Radius::from(8.0), + }; + style + } + ) + .padding(0) + .width(Length::from(28)) + .height(Length::from(28)) + .on_press(Message::ShowNewDialogTab) + ] + .align_y(Center) + .padding(4), + Space::with_height(Length::from(8)), devices, Space::with_height(Length::Fill), settings @@ -375,200 +591,38 @@ impl App { .center_x(Length::Fill) .center_y(Length::Fill) } else { - let mut information_col = column![]; - - let device_type = devices_list.get(id) - .map(|d| d.type_.clone()).unwrap(); - - if device_type == DeviceType::AirPods { - let device_information = devices_list.get(id) - .and_then(|d| d.information.clone()); - match device_information { - Some(DeviceInformation::AirPods(ref airpods_information)) => { - information_col = information_col - .push(text("Device Information").size(18).style( - |theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().primary); - style - } - )) - .push(Space::with_height(Length::from(10))) - .push( - row![ - text("Model Number").size(16).style( - |theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - } - ), - Space::with_width(Length::Fill), - text(airpods_information.model_number.clone()).size(16) - ] - ) - .push( - row![ - text("Manufacturer").size(16).style( - |theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - } - ), - Space::with_width(Length::Fill), - text(airpods_information.manufacturer.clone()).size(16) - ] - ) - .push( - row![ - text("Serial Number").size(16).style( - |theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - } - ), - Space::with_width(Length::Fill), - button( - text( - airpods_information.serial_number.clone() - ) - .size(16) - ) - .style( - |theme: &Theme, _status| { - let mut style = Style::default(); - style.text_color = theme.palette().text; - style.background = Some(Background::Color(Color::TRANSPARENT)); - style - } - ) - .padding(0) - .on_press(Message::CopyToClipboard(airpods_information.serial_number.clone())) - ] - ) - .push( - row![ - text("Left Serial Number").size(16).style( - |theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - } - ), - Space::with_width(Length::Fill), - button( - text( - airpods_information.left_serial_number.clone() - ) - .size(16) - ) - .style( - |theme: &Theme, _status| { - let mut style = Style::default(); - style.text_color = theme.palette().text; - style.background = Some(Background::Color(Color::TRANSPARENT)); - style - } - ) - .padding(0) - .on_press(Message::CopyToClipboard(airpods_information.left_serial_number.clone())) - ] - ) - .push( - row![ - text("Right Serial Number").size(16).style( - |theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - } - ), - Space::with_width(Length::Fill), - button( - text( - airpods_information.right_serial_number.clone() - ) - .size(16) - ) - .style( - |theme: &Theme, _status| { - let mut style = Style::default(); - style.text_color = theme.palette().text; - style.background = Some(Background::Color(Color::TRANSPARENT)); - style - } - ) - .padding(0) - .on_press(Message::CopyToClipboard(airpods_information.right_serial_number.clone())) - ] - ) - .push( - row![ - text("Version 1").size(16).style( - |theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - } - ), - Space::with_width(Length::Fill), - text(airpods_information.version1.clone()).size(16) - ] - ) - .push( - row![ - text("Version 2").size(16).style( - |theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - } - ), - Space::with_width(Length::Fill), - text(airpods_information.version2.clone()).size(16) - ] - ) - .push( - row![ - text("Version 3").size(16).style( - |theme: &Theme| { - let mut style = text::Style::default(); - style.color = Some(theme.palette().text); - style - } - ), - Space::with_width(Length::Fill), - text(airpods_information.version3.clone()).size(16) - ] - ); - debug!("AirPods Information: {:?}", airpods_information); + let device_type = devices_list.get(id).map(|d| d.type_.clone()); + let device_state = self.device_states.get(id); + debug!("Rendering device view for {}: type={:?}, state={:?}", id, device_type, device_state); + match device_type { + Some(DeviceType::AirPods) => { + if let Some(DeviceState::AirPods(state)) = device_state { + airpods_view(id, &devices_list, state) + } else { + container( + text("No state available for this AirPods device").size(16) + ) + .center_x(Length::Fill) + .center_y(Length::Fill) } - _ => { - error!("Expected AirPodsInformation, got something else: {:?}", device_information); - }, + } + Some(DeviceType::Nothing) => { + if let Some(DeviceState::Nothing(state)) = device_state { + nothing_view(id, &devices_list, state) + } else { + container( + text("No state available for this Nothing device").size(16) + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + } + } + _ => { + container(text("Unsupported device").size(16)) + .center_x(Length::Fill) + .center_y(Length::Fill) } } - container( - column![ - container(information_col) - .style( - |theme: &Theme| { - let mut style = container::Style::default(); - style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); - let mut border = Border::default(); - border.color = theme.palette().text; - style.border = border.rounded(20); - style - } - ) - .padding(20) - ] - ) - .padding(20) - .center_x(Length::Fill) - .height(Length::Fill) } } Tab::Settings => { @@ -579,7 +633,7 @@ impl App { row![ text("Theme:") .size(16), - Space::with_width(Length::from(10)), + Space::with_width(Length::Fill), combo_box( &self.theme_state, "Select theme", @@ -591,9 +645,9 @@ impl App { text_input::Style { background: Background::Color(Color::TRANSPARENT), border: Border { - width: 0.5, + width: 1.0, color: theme.palette().text, - radius: Radius::from(10.0), + radius: Radius::from(8.0), }, icon: Default::default(), placeholder: theme.palette().text.scale_alpha(0.5), @@ -607,9 +661,9 @@ impl App { menu::Style { background: Background::Color(Color::TRANSPARENT), border: Border { - width: 0.5, + width: 1.0, color: theme.palette().text, - radius: Radius::from(10.0) + radius: Radius::from(8.0) }, text_color: theme.palette().text, selected_text_color: theme.palette().text, @@ -617,7 +671,7 @@ impl App { } } ) - .width(Length::Fill) + .width(Length::from(350)) ] .align_y(Center) ] @@ -626,6 +680,162 @@ impl App { .width(Length::Fill) .height(Length::Fill) }, + Tab::AddDevice => { + container( + column![ + text("Pick a paired device to add:").size(18), + Space::with_height(Length::from(10)), + { + let mut list_col = column![].spacing(12); + for device in self.paired_devices.clone() { + if !devices_list.contains_key(&device.1.to_string()) { + let mut item_col = column![].spacing(8); + let mut row_elements = vec![ + column![ + text(device.0.to_string()).size(16), + text(device.1.to_string()).size(12) + ].into(), + Space::with_width(Length::Fill).into(), + ]; + // Only show "Add" button if this device is not the pending one + if !matches!(&self.pending_add_device, Some((_, addr)) if addr == &device.1) { + row_elements.push( + button( + text("Add").size(14).width(120).align_y(Center).align_x(Center) + ) + .style( + |theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.5))); + style.border = Border { + width: 1.0, + color: theme.palette().primary, + radius: Radius::from(8.0), + }; + style + } + ) + .padding(8) + .on_press(Message::StartAddDevice(device.0.clone(), device.1.clone())) + .into() + ); + } + item_col = item_col.push(row(row_elements).align_y(Center)); + + if let Some((_, pending_addr)) = &self.pending_add_device { + if pending_addr == &device.1 { + item_col = item_col.push( + row![ + text("Device Type:").size(16), + Space::with_width(Length::Fill), + combo_box( + &self.device_type_state, + "Select device type", + self.selected_device_type.as_ref(), + Message::SelectDeviceType + ) + .input_style( + |theme: &Theme, _status| { + text_input::Style { + background: Background::Color(theme.palette().background), + border: Border { + width: 1.0, + color: theme.palette().text, + radius: Radius::from(8.0), + }, + icon: Default::default(), + placeholder: theme.palette().text.scale_alpha(0.5), + value: theme.palette().text, + selection: theme.palette().primary + } + } + ) + .menu_style( + |theme: &Theme| { + menu::Style { + background: Background::Color(theme.palette().background), + border: Border { + width: 1.0, + color: theme.palette().text, + radius: Radius::from(8.0) + }, + text_color: theme.palette().text, + selected_text_color: theme.palette().text, + selected_background: Background::Color(theme.palette().primary.scale_alpha(0.3)), + } + } + ) + .width(Length::from(200)) + ] + ); + item_col = item_col.push( + row![ + Space::with_width(Length::Fill), + button(text("Cancel").size(16).width(Length::Fill).center()) + .on_press(Message::CancelAddDevice) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + style.text_color = theme.palette().text; + style.border = Border::default().rounded(8.0); + style + }) + .width(Length::from(120)) + .padding(4), + Space::with_width(Length::from(20)), + button(text("Add Device").size(16).width(Length::Fill).center()) + .on_press(Message::ConfirmAddDevice) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.3))); + style.text_color = theme.palette().text; + style.border = Border::default().rounded(8.0); + style + }) + .width(Length::from(120)) + .padding(4), + ] + .align_y(Center) + .width(Length::Fill) + ); + } + } + + list_col = list_col.push( + container(item_col) + .padding(8) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().text; + style.border = border.rounded(8); + style + } + ) + ); + } + } + if self.paired_devices.iter().all(|device| devices_list.contains_key(&device.1.to_string())) && self.pending_add_device.is_none() { + list_col = list_col.push( + container( + text("No new paired devices found. All paired devices are already added.").size(16) + ) + .width(Length::Fill) + ); + } + scrollable(list_col) + .height(Length::Fill) + .width(Length::Fill) + } + ] + ) + .padding(20) + .height(Length::Fill) + .width(Length::Fill) + } }; pane_grid::Content::new(content) @@ -649,14 +859,31 @@ impl App { } async fn wait_for_message( - ui_rx: Arc>>, + ui_rx: Arc>>, ) -> Message { let mut rx = ui_rx.lock().await; match rx.recv().await { - Some(msg) => Message::UIMessage(msg), + Some(msg) => Message::BluetoothMessage(msg), None => { error!("UI message channel closed"); - Message::UIMessage(UIMessage::NoOp) + Message::BluetoothMessage(BluetoothUIMessage::NoOp) } } -} \ No newline at end of file +} +async fn load_paired_devices() -> HashMap { + let mut devices = HashMap::new(); + + let session = Session::new().await.ok().unwrap(); + let adapter = session.default_adapter().await.ok().unwrap(); + let addresses = adapter.device_addresses().await.ok().unwrap(); + for addr in addresses { + let device = adapter.device(addr.clone()).ok().unwrap(); + let paired = device.is_paired().await.ok().unwrap(); + if paired { + let name = device.name().await.ok().flatten().unwrap_or_else(|| "Unknown".to_string()); + devices.insert(name, addr); + } + } + + devices +}