diff --git a/Cargo.lock b/Cargo.lock index d63c201d..2276ab71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -938,8 +938,9 @@ dependencies = [ [[package]] name = "nmrs" -version = "1.2.0" +version = "2.0.0-dev" dependencies = [ + "async-trait", "futures", "futures-timer", "log", diff --git a/README.md b/README.md index d06d2f0c..d80f1fad 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Edit `~/.config/nmrs/style.css` to customize the interface. There are also pre-d - [ ] Any - [X] Wired - [ ] ADSL -- [ ] Bluetooth +- [X] Bluetooth - [ ] Bond - [ ] Bridge - [ ] Dummy @@ -202,7 +202,7 @@ Edit `~/.config/nmrs/style.css` to customize the interface. There are also pre-d - [ ] DNS Manager - [ ] PPP - [ ] Secret Agent -- [ ] VPN Connection +- [X] VPN Connection (WireGuard) - [ ] VPN Plugin - [ ] Wi-Fi P2P - [ ] WiMAX NSP diff --git a/nmrs-gui/Cargo.toml b/nmrs-gui/Cargo.toml index fcafcd80..a4998c60 100644 --- a/nmrs-gui/Cargo.toml +++ b/nmrs-gui/Cargo.toml @@ -12,7 +12,7 @@ categories = ["gui"] publish = false [dependencies] -nmrs = { path = "../nmrs", version = "1.1.0" } +nmrs = { version = "2.0.0-dev", path = "../nmrs"} gtk = { version = "0.10.3", package = "gtk4" } glib = "0.21.5" tokio = { version = "1.48.0", features = ["full"] } diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index c55c5ef5..1875a9d4 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nmrs" -version = "1.2.0" +version = "2.0.0-dev" authors = ["Akrm Al-Hakimi "] edition.workspace = true rust-version = "1.78.0" @@ -21,6 +21,7 @@ thiserror.workspace = true uuid.workspace = true futures.workspace = true futures-timer.workspace = true +async-trait = "0.1.89" [dev-dependencies] tokio.workspace = true diff --git a/nmrs/examples/bluetooth.rs b/nmrs/examples/bluetooth.rs new file mode 100644 index 00000000..3d53a222 --- /dev/null +++ b/nmrs/examples/bluetooth.rs @@ -0,0 +1,17 @@ +/// List Bluetooth devices using NetworkManager +use nmrs::{NetworkManager, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + let nm = NetworkManager::new().await?; + + println!("Scanning for Bluetooth devices..."); + let devices = nm.list_bluetooth_devices().await?; + + // List bluetooth devices + for d in devices { + println!("{d}"); + } + + Ok(()) +} diff --git a/nmrs/examples/bluetooth_connect.rs b/nmrs/examples/bluetooth_connect.rs new file mode 100644 index 00000000..c07eb541 --- /dev/null +++ b/nmrs/examples/bluetooth_connect.rs @@ -0,0 +1,57 @@ +/// Connect to a Bluetooth device using NetworkManager. +use nmrs::models::BluetoothIdentity; +use nmrs::{NetworkManager, Result}; +#[tokio::main] +async fn main() -> Result<()> { + let nm = NetworkManager::new().await?; + + println!("Scanning for Bluetooth devices..."); + let devices = nm.list_bluetooth_devices().await?; + + if devices.is_empty() { + println!("No Bluetooth devices found."); + println!("\nMake sure:"); + println!(" 1. Bluetooth is enabled"); + println!(" 2. Device is paired (use 'bluetoothctl')"); + return Ok(()); + } + + // This will print all devices that have been explicitly paired using + // `bluetoothctl pair ` + println!("\nAvailable Bluetooth devices:"); + for (i, device) in devices.iter().enumerate() { + println!(" {}. {}", i + 1, device); + } + + // Connect to the first device in the list + if let Some(device) = devices.first() { + println!("\nConnecting to: {}", device); + + let settings = BluetoothIdentity { + bdaddr: device.bdaddr.clone(), + bt_device_type: device.bt_caps.into(), + }; + + let name = device + .alias + .as_ref() + .or(device.name.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("Bluetooth Device"); + + match nm.connect_bluetooth(name, &settings).await { + Ok(_) => println!("✓ Successfully connected to {name}"), + Err(e) => { + eprintln!("✗ Failed to connect: {}", e); + return Ok(()); + } + } + + /* match nm.forget_bluetooth(name).await { + Ok(_) => println!("Disconnected {name}"), + Err(e) => eprintln!("Failed to forget: {e}"), + }*/ + } + + Ok(()) +} diff --git a/nmrs/examples/vpn_connect.rs b/nmrs/examples/vpn_connect.rs index 65f30b6c..a398c63e 100644 --- a/nmrs/examples/vpn_connect.rs +++ b/nmrs/examples/vpn_connect.rs @@ -1,3 +1,4 @@ +/// Connect to a WireGuard VPN using NetworkManager and print the assigned IP address. use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; #[tokio::main] diff --git a/nmrs/examples/wifi_scan.rs b/nmrs/examples/wifi_scan.rs index 5ee858ee..19788373 100644 --- a/nmrs/examples/wifi_scan.rs +++ b/nmrs/examples/wifi_scan.rs @@ -1,3 +1,4 @@ +/// Scan for available WiFi networks and print their SSIDs and signal strengths. use nmrs::NetworkManager; #[tokio::main] diff --git a/nmrs/src/api/builders/bluetooth.rs b/nmrs/src/api/builders/bluetooth.rs new file mode 100644 index 00000000..030b133e --- /dev/null +++ b/nmrs/src/api/builders/bluetooth.rs @@ -0,0 +1,318 @@ +//! Bluetooth connection management module. +//! +//! This module provides functions to create and manage Bluetooth network connections +//! using NetworkManager's D-Bus API. It includes builders for Bluetooth PAN (Personal Area +//! Network) connections and DUN (Dial-Up Networking) connections. +//! +//! # Usage +//! +//! Most users should use the high-level [`NetworkManager`](crate::NetworkManager) API +//! instead of calling these builders directly. These are exposed for advanced use cases +//! where you need fine-grained control over connection settings. +//! +//! # Example +//! +//! ```rust +//! use nmrs::builders::build_bluetooth_connection; +//! use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; +//! +//! let bt_settings = BluetoothIdentity { +//! bdaddr: "00:1A:7D:DA:71:13".into(), +//! bt_device_type: BluetoothNetworkRole::PanU, +//! }; +//! ``` + +use std::collections::HashMap; +use zvariant::Value; + +use crate::{ + models::{BluetoothIdentity, BluetoothNetworkRole}, + ConnectionOptions, +}; + +/// Builds the `connection` section with type, id, uuid, and autoconnect settings. +pub fn base_connection_section( + name: &str, + opts: &ConnectionOptions, +) -> HashMap<&'static str, Value<'static>> { + let mut s = HashMap::new(); + s.insert("type", Value::from("bluetooth")); + s.insert("id", Value::from(name.to_string())); + s.insert("uuid", Value::from(uuid::Uuid::new_v4().to_string())); + s.insert("autoconnect", Value::from(opts.autoconnect)); + + if let Some(p) = opts.autoconnect_priority { + s.insert("autoconnect-priority", Value::from(p)); + } + + if let Some(r) = opts.autoconnect_retries { + s.insert("autoconnect-retries", Value::from(r)); + } + + s +} + +/// Builds a Bluetooth connection settings dictionary. +fn bluetooth_section(settings: &BluetoothIdentity) -> HashMap<&'static str, Value<'static>> { + let mut s = HashMap::new(); + s.insert("bdaddr", Value::from(settings.bdaddr.clone())); + let bt_type = match settings.bt_device_type { + BluetoothNetworkRole::PanU => "panu", + BluetoothNetworkRole::Dun => "dun", + }; + s.insert("type", Value::from(bt_type)); + s +} + +pub fn build_bluetooth_connection( + name: &str, + settings: &BluetoothIdentity, + opts: &ConnectionOptions, +) -> HashMap<&'static str, HashMap<&'static str, Value<'static>>> { + let mut conn: HashMap<&'static str, HashMap<&'static str, Value<'static>>> = HashMap::new(); + + // Base connections + conn.insert("connection", base_connection_section(name, opts)); + conn.insert("bluetooth", bluetooth_section(settings)); + + let mut ipv4 = HashMap::new(); + ipv4.insert("method", Value::from("auto")); + conn.insert("ipv4", ipv4); + + let mut ipv6 = HashMap::new(); + ipv6.insert("method", Value::from("auto")); + conn.insert("ipv6", ipv6); + + conn +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_opts() -> ConnectionOptions { + ConnectionOptions { + autoconnect: true, + autoconnect_priority: Some(10), + autoconnect_retries: Some(3), + } + } + + fn create_test_identity_panu() -> BluetoothIdentity { + BluetoothIdentity { + bdaddr: "00:1A:7D:DA:71:13".into(), + bt_device_type: BluetoothNetworkRole::PanU, + } + } + + fn create_test_identity_dun() -> BluetoothIdentity { + BluetoothIdentity { + bdaddr: "C8:1F:E8:F0:51:57".into(), + bt_device_type: BluetoothNetworkRole::Dun, + } + } + + #[test] + fn test_base_connection_section() { + let opts = create_test_opts(); + let section = base_connection_section("TestBluetooth", &opts); + + // Check required fields + assert!(section.contains_key("type")); + assert!(section.contains_key("id")); + assert!(section.contains_key("uuid")); + assert!(section.contains_key("autoconnect")); + + // Verify values + if let Some(Value::Str(conn_type)) = section.get("type") { + assert_eq!(conn_type.as_str(), "bluetooth"); + } else { + panic!("type field not found or wrong type"); + } + + if let Some(Value::Str(id)) = section.get("id") { + assert_eq!(id.as_str(), "TestBluetooth"); + } else { + panic!("id field not found or wrong type"); + } + + if let Some(Value::Bool(autoconnect)) = section.get("autoconnect") { + assert!(*autoconnect, "{}", true); + } else { + panic!("autoconnect field not found or wrong type"); + } + + // Check optional fields + assert!(section.contains_key("autoconnect-priority")); + assert!(section.contains_key("autoconnect-retries")); + } + + #[test] + fn test_base_connection_section_without_optional_fields() { + let opts = ConnectionOptions { + autoconnect: false, + autoconnect_priority: None, + autoconnect_retries: None, + }; + let section = base_connection_section("MinimalBT", &opts); + + assert!(section.contains_key("type")); + assert!(section.contains_key("id")); + assert!(section.contains_key("uuid")); + assert!(section.contains_key("autoconnect")); + + // Optional fields should not be present + assert!(!section.contains_key("autoconnect-priority")); + assert!(!section.contains_key("autoconnect-retries")); + } + + #[test] + fn test_bluetooth_section_panu() { + let identity = create_test_identity_panu(); + let section = bluetooth_section(&identity); + + assert!(section.contains_key("bdaddr")); + assert!(section.contains_key("type")); + + if let Some(Value::Str(bdaddr)) = section.get("bdaddr") { + assert_eq!(bdaddr.as_str(), "00:1A:7D:DA:71:13"); + } else { + panic!("bdaddr field not found or wrong type"); + } + + if let Some(Value::Str(bt_type)) = section.get("type") { + assert_eq!(bt_type.as_str(), "panu"); + } else { + panic!("type field not found or wrong type"); + } + } + + #[test] + fn test_bluetooth_section_dun() { + let identity = create_test_identity_dun(); + let section = bluetooth_section(&identity); + + assert!(section.contains_key("bdaddr")); + assert!(section.contains_key("type")); + + if let Some(Value::Str(bdaddr)) = section.get("bdaddr") { + assert_eq!(bdaddr.as_str(), "C8:1F:E8:F0:51:57"); + } else { + panic!("bdaddr field not found or wrong type"); + } + + if let Some(Value::Str(bt_type)) = section.get("type") { + assert_eq!(bt_type.as_str(), "dun"); + } else { + panic!("type field not found or wrong type"); + } + } + + #[test] + fn test_build_bluetooth_connection_panu() { + let identity = create_test_identity_panu(); + let opts = create_test_opts(); + let conn = build_bluetooth_connection("MyPhone", &identity, &opts); + + // Check main sections + assert!(conn.contains_key("connection")); + assert!(conn.contains_key("bluetooth")); + assert!(conn.contains_key("ipv4")); + assert!(conn.contains_key("ipv6")); + + // Verify connection section + let connection_section = conn.get("connection").unwrap(); + if let Some(Value::Str(id)) = connection_section.get("id") { + assert_eq!(id.as_str(), "MyPhone"); + } + + // Verify bluetooth section + let bt_section = conn.get("bluetooth").unwrap(); + if let Some(Value::Str(bdaddr)) = bt_section.get("bdaddr") { + assert_eq!(bdaddr.as_str(), "00:1A:7D:DA:71:13"); + } + if let Some(Value::Str(bt_type)) = bt_section.get("type") { + assert_eq!(bt_type.as_str(), "panu"); + } + + // Verify IP sections + let ipv4_section = conn.get("ipv4").unwrap(); + if let Some(Value::Str(method)) = ipv4_section.get("method") { + assert_eq!(method.as_str(), "auto"); + } + + let ipv6_section = conn.get("ipv6").unwrap(); + if let Some(Value::Str(method)) = ipv6_section.get("method") { + assert_eq!(method.as_str(), "auto"); + } + } + + #[test] + fn test_build_bluetooth_connection_dun() { + let identity = create_test_identity_dun(); + let opts = ConnectionOptions { + autoconnect: false, + autoconnect_priority: None, + autoconnect_retries: None, + }; + let conn = build_bluetooth_connection("MobileHotspot", &identity, &opts); + + assert!(conn.contains_key("connection")); + assert!(conn.contains_key("bluetooth")); + assert!(conn.contains_key("ipv4")); + assert!(conn.contains_key("ipv6")); + + // Verify DUN type + let bt_section = conn.get("bluetooth").unwrap(); + if let Some(Value::Str(bt_type)) = bt_section.get("type") { + assert_eq!(bt_type.as_str(), "dun"); + } + } + + #[test] + fn test_uuid_is_unique() { + let identity = create_test_identity_panu(); + let opts = create_test_opts(); + + let conn1 = build_bluetooth_connection("BT1", &identity, &opts); + let conn2 = build_bluetooth_connection("BT2", &identity, &opts); + + let uuid1 = if let Some(section) = conn1.get("connection") { + if let Some(Value::Str(uuid)) = section.get("uuid") { + uuid.as_str() + } else { + panic!("uuid not found in conn1"); + } + } else { + panic!("connection section not found in conn1"); + }; + + let uuid2 = if let Some(section) = conn2.get("connection") { + if let Some(Value::Str(uuid)) = section.get("uuid") { + uuid.as_str() + } else { + panic!("uuid not found in conn2"); + } + } else { + panic!("connection section not found in conn2"); + }; + + // UUIDs should be different + assert_ne!(uuid1, uuid2, "UUIDs should be unique"); + } + + #[test] + fn test_bdaddr_format_preserved() { + let identity = BluetoothIdentity { + bdaddr: "AA:BB:CC:DD:EE:FF".into(), + bt_device_type: BluetoothNetworkRole::PanU, + }; + let opts = create_test_opts(); + let conn = build_bluetooth_connection("Test", &identity, &opts); + + let bt_section = conn.get("bluetooth").unwrap(); + if let Some(Value::Str(bdaddr)) = bt_section.get("bdaddr") { + assert_eq!(bdaddr.as_str(), "AA:BB:CC:DD:EE:FF"); + } + } +} diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index 320ec333..6588c4ef 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -18,9 +18,9 @@ //! //! # Examples //! -//! ```rust -//! use nmrs::builders::{build_wifi_connection, build_ethernet_connection}; -//! use nmrs::{WifiSecurity, ConnectionOptions}; +//! ```ignore +//! use nmrs::builders::{build_wifi_connection, build_wireguard_connection, build_ethernet_connection}; +//! use nmrs::{WifiSecurity, ConnectionOptions, VpnCredentials, VpnType, WireGuardPeer}; //! //! let opts = ConnectionOptions { //! autoconnect: true, @@ -37,11 +37,6 @@ //! //! // Build Ethernet connection settings //! let eth_settings = build_ethernet_connection("eth0", &opts); -//! ``` -//! -//! ```rust -//! # use nmrs::builders::build_wireguard_connection; -//! # use nmrs::{VpnCredentials, VpnType, WireGuardPeer, ConnectionOptions}; //! // Build WireGuard VPN connection settings //! let opts = ConnectionOptions { //! autoconnect: true, @@ -73,6 +68,7 @@ //! These settings can then be passed to NetworkManager's //! `AddConnection` or `AddAndActivateConnection` D-Bus methods. +pub mod bluetooth; pub mod connection_builder; pub mod vpn; pub mod wifi; @@ -84,6 +80,7 @@ pub use connection_builder::{ConnectionBuilder, IpConfig, Route}; pub use wifi_builder::{WifiBand, WifiConnectionBuilder}; pub use wireguard_builder::WireGuardBuilder; -// Re-export builder functions for convenience (backward compatibility) +// Re-export builder functions for convenience +pub use bluetooth::build_bluetooth_connection; pub use vpn::build_wireguard_connection; pub use wifi::{build_ethernet_connection, build_wifi_connection}; diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index af096f41..531323e3 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -7,6 +7,7 @@ use uuid::Uuid; /// /// These values represent the lifecycle states of an active connection /// as reported by the NM D-Bus API. +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ActiveConnectionState { /// Connection state is unknown. @@ -54,6 +55,7 @@ impl Display for ActiveConnectionState { /// These values indicate why an active connection transitioned to its /// current state. Use `ConnectionStateReason::from(code)` to convert /// from the raw u32 values returned by NetworkManager signals. +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConnectionStateReason { /// The reason is unknown. @@ -166,6 +168,7 @@ pub fn connection_state_reason_to_error(code: u32) -> ConnectionError { /// These values come from the NM D-Bus API and indicate why a device /// transitioned to its current state. Use `StateReason::from(code)` to /// convert from the raw u32 values returned by NetworkManager. +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StateReason { /// The reason is unknown. @@ -396,6 +399,8 @@ pub struct NetworkInfo { /// println!(" This is a WiFi device"); /// } else if device.is_wired() { /// println!(" This is an Ethernet device"); +/// } else if device.is_bluetooth() { +/// println!(" This is a Bluetooth device"); /// } /// /// if let Some(driver) = &device.driver { @@ -421,6 +426,8 @@ pub struct Device { pub managed: Option, /// Kernel driver name pub driver: Option, + // Link speed in Mb/s (wired devices) + // pub speed: Option, } /// Represents the hardware identity of a network device. @@ -832,9 +839,82 @@ pub struct VpnConnectionInfo { pub dns_servers: Vec, } +/// Bluetooth network role. +/// +/// Specifies the role of the Bluetooth device in the network connection. +/// +/// # Stability +/// +/// This enum is marked as `#[non_exhaustive]` so as to assume that new Bluetooth roles may be +/// added in future versions. When pattern matching, always include a wildcard arm. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BluetoothNetworkRole { + PanU, // Personal Area Network User + Dun, // Dial-Up Networking +} + +/// Bluetooth device identity information. +/// +/// Relevant info for Bluetooth devices managed by NetworkManager. +/// +/// # Example +///```rust +/// use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; +/// +/// let bt_settings = BluetoothIdentity { +/// bdaddr: "00:1A:7D:DA:71:13".into(), +/// bt_device_type: BluetoothNetworkRole::Dun, +/// }; +/// ``` +#[derive(Debug, Clone)] +pub struct BluetoothIdentity { + /// MAC address of Bluetooth device + pub bdaddr: String, + /// Bluetooth device type (DUN or PANU) + pub bt_device_type: BluetoothNetworkRole, +} + +/// Bluetooth device with friendly name from BlueZ. +/// +/// Contains information about a Bluetooth device managed by NetworkManager, +/// proxying data from BlueZ. +/// +/// This is a specialized struct for Bluetooth devices, separate from the +/// general `Device` struct. +/// +/// # Example +/// +/// ```rust +/// use nmrs::models::{BluetoothDevice, BluetoothNetworkRole, DeviceState}; +/// +/// let role = BluetoothNetworkRole::PanU as u32; +/// let bt_device = BluetoothDevice { +/// bdaddr: "00:1A:7D:DA:71:13".into(), +/// name: Some("Foo".into()), +/// alias: Some("Bar".into()), +/// bt_caps: role, +/// state: DeviceState::Activated, +/// }; +/// ``` +#[derive(Debug, Clone)] +pub struct BluetoothDevice { + /// Bluetooth MAC address + pub bdaddr: String, + /// Friendly device name from BlueZ + pub name: Option, + /// Device alias from BlueZ + pub alias: Option, + /// Bluetooth device type (DUN or PANU) + pub bt_caps: u32, + /// Current device state + pub state: DeviceState, +} + /// NetworkManager device types. /// /// Represents the type of network hardware managed by NetworkManager. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq)] pub enum DeviceType { /// Wired Ethernet device. @@ -845,6 +925,8 @@ pub enum DeviceType { WifiP2P, /// Loopback device (localhost). Loopback, + /// Bluetooth + Bluetooth, /// Unknown or unsupported device type with raw code. Other(u32), } @@ -852,6 +934,7 @@ pub enum DeviceType { /// NetworkManager device states. /// /// Represents the current operational state of a network device. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq)] pub enum DeviceState { /// Device is not managed by NetworkManager. @@ -884,6 +967,52 @@ impl Device { pub fn is_wireless(&self) -> bool { matches!(self.device_type, DeviceType::Wifi) } + + /// Returns 'true' if this is a Bluetooth (DUN or PANU) device. + pub fn is_bluetooth(&self) -> bool { + matches!(self.device_type, DeviceType::Bluetooth) + } +} + +/// Display implementation for Device struct. +/// +/// Formats the device information as "interface (device_type) [state]". +impl Display for Device { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} ({}) [{}]", + self.interface, self.device_type, self.state + ) + } +} + +/// Display implementation for BluetoothDevice struct. +/// +/// Formats the device information as "alias (device_type) [bdaddr]". +impl Display for BluetoothDevice { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let role = BluetoothNetworkRole::from(self.bt_caps); + write!( + f, + "{} ({}) [{}]", + self.alias.as_deref().unwrap_or("unknown"), + role, + self.bdaddr + ) + } +} + +/// Display implementation for Device struct. +/// +/// Formats the device information as "interface (device_type) [state]". +impl Display for BluetoothNetworkRole { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + BluetoothNetworkRole::Dun => write!(f, "DUN"), + BluetoothNetworkRole::PanU => write!(f, "PANU"), + } + } } /// Errors that can occur during network operations. @@ -944,6 +1073,7 @@ impl Device { /// # Ok(()) /// # } /// ``` +#[non_exhaustive] #[derive(Debug, Error)] pub enum ConnectionError { /// A D-Bus communication error occurred. @@ -1033,6 +1163,10 @@ pub enum ConnectionError { /// VPN connection failed #[error("VPN connection failed: {0}")] VpnFailed(String), + + /// Bluetooth device not found + #[error("Bluetooth device not found")] + NoBluetoothDevice, } /// NetworkManager device state reason codes. @@ -1188,6 +1322,7 @@ impl From for DeviceType { match value { 1 => DeviceType::Ethernet, 2 => DeviceType::Wifi, + 5 => DeviceType::Bluetooth, 30 => DeviceType::WifiP2P, 32 => DeviceType::Loopback, v => DeviceType::Other(v), @@ -1218,6 +1353,7 @@ impl Display for DeviceType { DeviceType::Wifi => write!(f, "Wi-Fi"), DeviceType::WifiP2P => write!(f, "Wi-Fi P2P"), DeviceType::Loopback => write!(f, "Loopback"), + DeviceType::Bluetooth => write!(f, "Bluetooth"), DeviceType::Other(v) => write!(f, "Other({v})"), } } @@ -1239,6 +1375,16 @@ impl Display for DeviceState { } } +impl From for BluetoothNetworkRole { + fn from(value: u32) -> Self { + match value { + 0 => Self::PanU, + 1 => Self::Dun, + _ => Self::PanU, + } + } +} + impl WifiSecurity { /// Returns `true` if this security type requires authentication. pub fn secured(&self) -> bool { @@ -1637,4 +1783,131 @@ mod tests { "connection activation failed: no secrets (password) provided" ); } + + #[test] + fn test_bluetooth_network_role_from_u32() { + assert_eq!(BluetoothNetworkRole::from(0), BluetoothNetworkRole::PanU); + assert_eq!(BluetoothNetworkRole::from(1), BluetoothNetworkRole::Dun); + // Unknown values default to PanU + assert_eq!(BluetoothNetworkRole::from(999), BluetoothNetworkRole::PanU); + } + + #[test] + fn test_bluetooth_network_role_display() { + assert_eq!(format!("{}", BluetoothNetworkRole::PanU), "PANU"); + assert_eq!(format!("{}", BluetoothNetworkRole::Dun), "DUN"); + } + + #[test] + fn test_bluetooth_identity_creation() { + let identity = BluetoothIdentity { + bdaddr: "00:1A:7D:DA:71:13".into(), + bt_device_type: BluetoothNetworkRole::PanU, + }; + + assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); + assert!(matches!( + identity.bt_device_type, + BluetoothNetworkRole::PanU + )); + } + + #[test] + fn test_bluetooth_identity_dun() { + let identity = BluetoothIdentity { + bdaddr: "C8:1F:E8:F0:51:57".into(), + bt_device_type: BluetoothNetworkRole::Dun, + }; + + assert_eq!(identity.bdaddr, "C8:1F:E8:F0:51:57"); + assert!(matches!(identity.bt_device_type, BluetoothNetworkRole::Dun)); + } + + #[test] + fn test_bluetooth_device_creation() { + let role = BluetoothNetworkRole::PanU as u32; + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_caps: role, + state: DeviceState::Activated, + }; + + assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); + assert_eq!(device.name, Some("MyPhone".into())); + assert_eq!(device.alias, Some("Phone".into())); + assert!(matches!(device.bt_caps, _role)); + assert_eq!(device.state, DeviceState::Activated); + } + + #[test] + fn test_bluetooth_device_display() { + let role = BluetoothNetworkRole::PanU as u32; + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_caps: role, + state: DeviceState::Activated, + }; + + let display_str = format!("{}", device); + assert!(display_str.contains("Phone")); + assert!(display_str.contains("00:1A:7D:DA:71:13")); + assert!(display_str.contains("PANU")); + } + + #[test] + fn test_bluetooth_device_display_no_alias() { + let role = BluetoothNetworkRole::Dun as u32; + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: None, + bt_caps: role, + state: DeviceState::Disconnected, + }; + + let display_str = format!("{}", device); + assert!(display_str.contains("unknown")); + assert!(display_str.contains("00:1A:7D:DA:71:13")); + assert!(display_str.contains("DUN")); + } + + #[test] + fn test_device_is_bluetooth() { + let bt_device = Device { + path: "/org/freedesktop/NetworkManager/Devices/1".into(), + interface: "bt0".into(), + identity: DeviceIdentity { + permanent_mac: "00:1A:7D:DA:71:13".into(), + current_mac: "00:1A:7D:DA:71:13".into(), + }, + device_type: DeviceType::Bluetooth, + state: DeviceState::Activated, + managed: Some(true), + driver: Some("btusb".into()), + }; + + assert!(bt_device.is_bluetooth()); + assert!(!bt_device.is_wireless()); + assert!(!bt_device.is_wired()); + } + + #[test] + fn test_device_type_bluetooth() { + assert_eq!(DeviceType::from(5), DeviceType::Bluetooth); + } + + #[test] + fn test_device_type_bluetooth_display() { + assert_eq!(format!("{}", DeviceType::Bluetooth), "Bluetooth"); + } + + #[test] + fn test_connection_error_no_bluetooth_device() { + let err = ConnectionError::NoBluetoothDevice; + assert_eq!(format!("{}", err), "Bluetooth device not found"); + } } diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index aa9a2ca2..cccd1d60 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -1,15 +1,22 @@ use zbus::Connection; use crate::api::models::{Device, Network, NetworkInfo, WifiSecurity}; -use crate::core::connection::{connect, connect_wired, forget}; +use crate::core::bluetooth::connect_bluetooth; +use crate::core::connection::{connect, connect_wired, forget_by_name_and_type}; use crate::core::connection_settings::{get_saved_connection_path, has_saved_connection}; -use crate::core::device::{list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled}; +use crate::core::device::{ + list_bluetooth_devices, list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled, +}; use crate::core::scan::{list_networks, scan_networks}; use crate::core::vpn::{connect_vpn, disconnect_vpn, get_vpn_info, list_vpn_connections}; -use crate::models::{VpnConnection, VpnConnectionInfo, VpnCredentials}; +use crate::models::{ + BluetoothDevice, BluetoothIdentity, VpnConnection, VpnConnectionInfo, VpnCredentials, +}; use crate::monitoring::device as device_monitor; -use crate::monitoring::info::{current_connection_info, current_ssid, show_details}; +use crate::monitoring::info::show_details; use crate::monitoring::network as network_monitor; +use crate::monitoring::wifi::{current_connection_info, current_ssid}; +use crate::types::constants::device_type; use crate::Result; /// High-level interface to NetworkManager over D-Bus. @@ -118,6 +125,11 @@ impl NetworkManager { list_devices(&self.conn).await } + /// List all bluetooth devices. + pub async fn list_bluetooth_devices(&self) -> Result> { + list_bluetooth_devices(&self.conn).await + } + /// Lists all network devices managed by NetworkManager. pub async fn list_wireless_devices(&self) -> Result> { let devices = list_devices(&self.conn).await?; @@ -159,6 +171,30 @@ impl NetworkManager { connect_wired(&self.conn).await } + /// Connects to a bluetooth device using the provided identity. + /// + /// # Example + /// + /// ```no_run + /// use nmrs::{NetworkManager, models::BluetoothIdentity, models::BluetoothNetworkRole}; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// + /// let identity = BluetoothIdentity { + /// bdaddr: "C8:1F:E8:F0:51:57".into(), + /// bt_device_type: BluetoothNetworkRole::PanU, + /// }; + /// + /// nm.connect_bluetooth("My Phone", &identity).await?; + /// Ok(()) + /// } + /// + /// ``` + pub async fn connect_bluetooth(&self, name: &str, identity: &BluetoothIdentity) -> Result<()> { + connect_bluetooth(&self.conn, name, identity).await + } + /// Connects to a VPN using the provided credentials. /// /// Currently supports WireGuard VPN connections. The function checks for an @@ -168,7 +204,7 @@ impl NetworkManager { /// /// # Example /// - /// ```no_run + /// ```rust /// use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; /// /// # async fn example() -> nmrs::Result<()> { @@ -361,11 +397,34 @@ impl NetworkManager { get_saved_connection_path(&self.conn, ssid).await } - /// Forgets (deletes) a saved connection for the given SSID. + /// Forgets (deletes) a saved WiFi connection for the given SSID. /// - /// If currently connected to this network, disconnects first. + /// If currently connected to this network, disconnects first, then deletes + /// all saved connection profiles matching the SSID. + /// + /// # Returns + /// + /// Returns `Ok(())` if at least one connection was deleted successfully. + /// Returns `NoSavedConnection` if no matching connections were found. pub async fn forget(&self, ssid: &str) -> Result<()> { - forget(&self.conn, ssid).await + forget_by_name_and_type(&self.conn, ssid, Some(device_type::WIFI)).await + } + + /// Forgets (deletes) a saved Bluetooth connection. + /// + /// If currently connected to this device, it will disconnect first before + /// deleting the connection profile. Can match by connection name or bdaddr. + /// + /// # Arguments + /// + /// * `name` - Connection name or bdaddr to forget + /// + /// # Returns + /// + /// Returns `Ok(())` if the connection was deleted successfully. + /// Returns `NoSavedConnection` if no matching connection was found. + pub async fn forget_bluetooth(&self, name: &str) -> Result<()> { + forget_by_name_and_type(&self.conn, name, Some(device_type::BLUETOOTH)).await } /// Monitors Wi-Fi network changes in real-time. diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs new file mode 100644 index 00000000..515d3ae0 --- /dev/null +++ b/nmrs/src/core/bluetooth.rs @@ -0,0 +1,271 @@ +//! Core Bluetooth connection management logic. +//! +//! This module contains the internal implementation details for managing +//! Bluetooth devices and connections. +//! +//! Similar to other device types, it handles scanning, connecting, and monitoring +//! Bluetooth devices using NetworkManager's D-Bus API. + +use log::debug; +use zbus::Connection; +use zvariant::OwnedObjectPath; +// use futures_timer::Delay; + +use crate::builders::bluetooth; +use crate::core::connection_settings::get_saved_connection_path; +use crate::core::state_wait::{wait_for_connection_activation, wait_for_device_disconnect}; +use crate::dbus::{BluezDeviceExtProxy, NMDeviceProxy}; +use crate::monitoring::bluetooth::Bluetooth; +use crate::monitoring::transport::ActiveTransport; +use crate::types::constants::device_state; +use crate::types::constants::device_type; +use crate::ConnectionError; +use crate::{dbus::NMProxy, models::BluetoothIdentity, Result}; + +/// Populated Bluetooth device information via BlueZ. +/// +/// Given a Bluetooth device address (BDADDR), this function queries BlueZ +/// over D-Bus to retrieve the device's name and alias. It constructs the +/// appropriate D-Bus object path based on the BDADDR format. +/// +/// NetworkManager does not expose Bluetooth device names/aliases directly, +/// hence this additional step is necessary to obtain user-friendly +/// identifiers for Bluetooth devices. (See `BluezDeviceExtProxy` for details.) +pub(crate) async fn populate_bluez_info( + conn: &Connection, + bdaddr: &str, +) -> Result<(Option, Option)> { + // [variable prefix]/{hci0,hci1,...}/dev_XX_XX_XX_XX_XX_XX + // This replaces ':' with '_' in the BDADDR to form the correct D-Bus object path. + // TODO: Instead of hardcoding hci0, we should determine the actual adapter name. + let bluez_path = format!("/org/bluez/hci0/dev_{}", bdaddr.replace(':', "_")); + + match BluezDeviceExtProxy::builder(conn) + .path(bluez_path)? + .build() + .await + { + Ok(proxy) => { + let name = proxy.name().await.ok(); + let alias = proxy.alias().await.ok(); + Ok((name, alias)) + } + Err(_) => Ok((None, None)), + } +} + +pub(crate) async fn find_bluetooth_device( + conn: &Connection, + nm: &NMProxy<'_>, +) -> Result { + let devices = nm.get_devices().await?; + + for dp in devices { + let dev = NMDeviceProxy::builder(conn) + .path(dp.clone())? + .build() + .await?; + if dev.device_type().await? == device_type::BLUETOOTH { + return Ok(dp); + } + } + Err(ConnectionError::NoBluetoothDevice) +} + +/// Connects to a Bluetooth device using NetworkManager. +/// +/// This function establishes a Bluetooth network connection. The flow: +/// 1. Check if already connected to this device +/// 2. Find the Bluetooth hardware adapter +/// 3. Check for an existing saved connection +/// 4. Either activate the saved connection or create a new one +/// 5. Wait for the connection to reach the activated state +/// +/// **Important:** The Bluetooth device must already be paired via BlueZ +/// (using `bluetoothctl` or similar) before NetworkManager can connect to it. +/// +/// # Arguments +/// +/// * `conn` - D-Bus connection +/// * `name` - Connection name/identifier +/// * `settings` - Bluetooth device settings (bdaddr and type) +/// +/// # Example +/// +/// ```no_run +/// use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; +/// +/// let settings = BluetoothIdentity { +/// bdaddr: "C8:1F:E8:F0:51:57".into(), +/// bt_device_type: BluetoothNetworkRole::PanU, +/// }; +/// // connect_bluetooth(&conn, "My Phone", &settings).await?; +/// ``` +pub(crate) async fn connect_bluetooth( + conn: &Connection, + name: &str, + settings: &BluetoothIdentity, +) -> Result<()> { + debug!( + "Connecting to '{}' (Bluetooth) | bdaddr={} type={:?}", + name, settings.bdaddr, settings.bt_device_type + ); + + let nm = NMProxy::new(conn).await?; + + // Check if already connected to this device + if let Some(active) = Bluetooth::current(conn).await { + debug!("Currently connected to Bluetooth device: {active}"); + if active == settings.bdaddr { + debug!("Already connected to {active}, skipping connect()"); + return Ok(()); + } + } else { + debug!("Not currently connected to any Bluetooth device"); + } + + // Find the Bluetooth hardware adapter + // Note: Unlike WiFi, Bluetooth connections in NetworkManager don't require + // specifying a specific device. We use "/" to let NetworkManager auto-select. + let bt_device = find_bluetooth_device(conn, &nm).await?; + debug!("Using auto-select device path for Bluetooth connection"); + + // Check for saved connection + let saved = get_saved_connection_path(conn, name).await?; + + // For Bluetooth, the "specific_object" is the remote device's D-Bus path + // Format: /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX + // TODO: Instead of hardcoding the hci0, we should use the actual hardware adapter name. + let specific_object = OwnedObjectPath::try_from(format!( + "/org/bluez/hci0/dev_{}", + settings.bdaddr.replace(':', "_") + )) + .map_err(|e| ConnectionError::InvalidAddress(format!("Invalid BlueZ path: {}", e)))?; + + match saved { + Some(saved_path) => { + debug!( + "Activating saved Bluetooth connection: {}", + saved_path.as_str() + ); + let active_conn = nm + .activate_connection(saved_path, bt_device.clone(), specific_object) + .await?; + + crate::core::state_wait::wait_for_connection_activation(conn, &active_conn).await?; + } + None => { + debug!("No saved connection found, creating new Bluetooth connection"); + let opts = crate::api::models::ConnectionOptions { + autoconnect: false, // Bluetooth typically doesn't auto-connect + autoconnect_priority: None, + autoconnect_retries: None, + }; + + let connection_settings = bluetooth::build_bluetooth_connection(name, settings, &opts); + + println!( + "Creating Bluetooth connection with settings: {:#?}", + connection_settings + ); + + let (_, active_conn) = nm + .add_and_activate_connection( + connection_settings, + bt_device.clone(), + specific_object, + ) + .await?; + + wait_for_connection_activation(conn, &active_conn).await?; + } + } + + log::info!("Successfully connected to Bluetooth device '{name}'"); + Ok(()) +} + +/// Disconnects a Bluetooth device and waits for it to reach disconnected state. +/// +/// Calls the Disconnect method on the device and waits for the `StateChanged` +/// signal to indicate the device has reached Disconnected or Unavailable state. +pub(crate) async fn disconnect_bluetooth_and_wait( + conn: &Connection, + dev_path: &OwnedObjectPath, +) -> Result<()> { + let dev = NMDeviceProxy::builder(conn) + .path(dev_path.clone())? + .build() + .await?; + + // Check if already disconnected + let current_state = dev.state().await?; + if current_state == device_state::DISCONNECTED || current_state == device_state::UNAVAILABLE { + debug!("Bluetooth device already disconnected"); + return Ok(()); + } + + let raw: zbus::proxy::Proxy = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(dev_path.clone())? + .interface("org.freedesktop.NetworkManager.Device")? + .build() + .await?; + + debug!("Sending disconnect request to Bluetooth device"); + let _ = raw.call_method("Disconnect", &()).await; + + // Wait for disconnect using signal-based monitoring + wait_for_device_disconnect(&dev).await?; + + // Brief stabilization delay + // Delay::new(timeouts::stabilization_delay()).await; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::BluetoothNetworkRole; + + #[test] + fn test_bluez_path_format() { + // Test that bdaddr format is converted correctly for D-Bus path + let bdaddr = "00:1A:7D:DA:71:13"; + let expected_path = "/org/bluez/hci0/dev_00_1A_7D_DA_71_13"; + let actual_path = format!("/org/bluez/hci0/dev_{}", bdaddr.replace(':', "_")); + assert_eq!(actual_path, expected_path); + } + + #[test] + fn test_bluez_path_format_various_addresses() { + let test_cases = vec![ + ("AA:BB:CC:DD:EE:FF", "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"), + ("00:00:00:00:00:00", "/org/bluez/hci0/dev_00_00_00_00_00_00"), + ("C8:1F:E8:F0:51:57", "/org/bluez/hci0/dev_C8_1F_E8_F0_51_57"), + ]; + + for (bdaddr, expected_path) in test_cases { + let actual_path = format!("/org/bluez/hci0/dev_{}", bdaddr.replace(':', "_")); + assert_eq!(actual_path, expected_path, "Failed for bdaddr: {}", bdaddr); + } + } + + #[test] + fn test_bluetooth_identity_structure() { + let identity = BluetoothIdentity { + bdaddr: "00:1A:7D:DA:71:13".into(), + bt_device_type: BluetoothNetworkRole::PanU, + }; + + assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); + assert!(matches!( + identity.bt_device_type, + BluetoothNetworkRole::PanU + )); + } + + // Note: Most of the core connection functions require a real D-Bus connection + // and NetworkManager running, so they are better suited for integration tests. +} diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index f2af227d..767d8f0d 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -8,8 +8,9 @@ use crate::api::builders::wifi::{build_ethernet_connection, build_wifi_connectio use crate::api::models::{ConnectionError, ConnectionOptions, WifiSecurity}; use crate::core::connection_settings::{delete_connection, get_saved_connection_path}; use crate::core::state_wait::{wait_for_connection_activation, wait_for_device_disconnect}; -use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; -use crate::monitoring::info::current_ssid; +use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWiredProxy, NMWirelessProxy}; +use crate::monitoring::transport::ActiveTransport; +use crate::monitoring::wifi::Wifi; use crate::types::constants::{device_state, device_type, timeouts}; use crate::util::utils::{decode_ssid_or_empty, nm_proxy}; use crate::Result; @@ -55,7 +56,7 @@ pub(crate) async fn connect(conn: &Connection, ssid: &str, creds: WifiSecurity) .build() .await?; - if let Some(active) = current_ssid(conn).await { + if let Some(active) = Wifi::current(conn).await { debug!("Currently connected to: {active}"); if active == ssid { debug!("Already connected to {active}, skipping connect()"); @@ -144,70 +145,131 @@ pub(crate) async fn connect_wired(conn: &Connection) -> Result<()> { } } + if let Ok(wired) = NMWiredProxy::builder(conn) + .path(wired_device.clone())? + .build() + .await + { + if let Ok(speed) = wired.speed().await { + info!("Connected to wired device at {speed} Mb/s"); + } + } + info!("Successfully connected to wired device"); Ok(()) } -/// Forgets (deletes) all saved connections for a network. +/// Generic function to forget (delete) connections by name and optionally by device type. +/// +/// This handles disconnection if currently active, then deletes the connection profile(s). +/// Can be used for WiFi, Bluetooth, or any NetworkManager connection type. +/// +/// # Arguments /// -/// If currently connected to this network, disconnects first, then deletes -/// all saved connection profiles matching the SSID. Matches are found by -/// both the connection ID and the wireless SSID bytes. +/// * `conn` - D-Bus connection +/// * `name` - Connection name/identifier to forget +/// * `device_filter` - Optional device type filter (e.g., `Some(device_type::BLUETOOTH)`) /// +/// # Returns +/// +/// Returns `Ok(())` if at least one connection was deleted successfully. /// Returns `NoSavedConnection` if no matching connections were found. -pub(crate) async fn forget(conn: &Connection, ssid: &str) -> Result<()> { +pub(crate) async fn forget_by_name_and_type( + conn: &Connection, + name: &str, + device_filter: Option, +) -> Result<()> { use std::collections::HashMap; use zvariant::{OwnedObjectPath, Value}; - debug!("Starting forget operation for: {ssid}"); + debug!( + "Starting forget operation for: {name} (device filter: {:?})", + device_filter + ); let nm = NMProxy::new(conn).await?; + // Disconnect if currently active let devices = nm.get_devices().await?; for dev_path in &devices { let dev = NMDeviceProxy::builder(conn) .path(dev_path.clone())? .build() .await?; - if dev.device_type().await? != device_type::WIFI { - continue; + + let dev_type = dev.device_type().await?; + + // Skip if device type doesn't match our filter + if let Some(filter) = device_filter { + if dev_type != filter { + continue; + } } - let wifi = NMWirelessProxy::builder(conn) - .path(dev_path.clone())? - .build() - .await?; - if let Ok(ap_path) = wifi.active_access_point().await { - if ap_path.as_str() != "/" { - let ap = NMAccessPointProxy::builder(conn) - .path(ap_path.clone())? - .build() - .await?; - if let Ok(bytes) = ap.ssid().await { - if decode_ssid_or_empty(&bytes) == ssid { - debug!("Disconnecting from active network: {ssid}"); - if let Err(e) = disconnect_wifi_and_wait(conn, dev_path).await { - warn!("Disconnect wait failed: {e}"); - let final_state = dev.state().await?; - if final_state != device_state::DISCONNECTED - && final_state != device_state::UNAVAILABLE - { - error!( - "Device still connected (state: {final_state}), cannot safely delete" - ); - return Err(ConnectionError::Stuck(format!( - "disconnect failed, device in state {final_state}" - ))); + // Handle WiFi-specific disconnect logic + if dev_type == device_type::WIFI { + let wifi = NMWirelessProxy::builder(conn) + .path(dev_path.clone())? + .build() + .await?; + if let Ok(ap_path) = wifi.active_access_point().await { + if ap_path.as_str() != "/" { + let ap = NMAccessPointProxy::builder(conn) + .path(ap_path.clone())? + .build() + .await?; + if let Ok(bytes) = ap.ssid().await { + if decode_ssid_or_empty(&bytes) == name { + debug!("Disconnecting from active WiFi network: {name}"); + if let Err(e) = disconnect_wifi_and_wait(conn, dev_path).await { + warn!("Disconnect wait failed: {e}"); + let final_state = dev.state().await?; + if final_state != device_state::DISCONNECTED + && final_state != device_state::UNAVAILABLE + { + error!( + "Device still connected (state: {final_state}), cannot safely delete" + ); + return Err(ConnectionError::Stuck(format!( + "disconnect failed, device in state {final_state}" + ))); + } + debug!("Device confirmed disconnected, proceeding with deletion"); } - debug!("Device confirmed disconnected, proceeding with deletion"); + debug!("WiFi disconnect phase completed"); } - debug!("Disconnect phase completed"); } } } } + // Handle Bluetooth-specific disconnect logic + else if dev_type == device_type::BLUETOOTH { + // Check if this Bluetooth device is currently active + let state = dev.state().await?; + if state != device_state::DISCONNECTED && state != device_state::UNAVAILABLE { + debug!("Disconnecting from active Bluetooth device: {name}"); + if let Err(e) = + crate::core::bluetooth::disconnect_bluetooth_and_wait(conn, dev_path).await + { + warn!("Bluetooth disconnect failed: {e}"); + let final_state = dev.state().await?; + if final_state != device_state::DISCONNECTED + && final_state != device_state::UNAVAILABLE + { + error!( + "Bluetooth device still connected (state: {final_state}), cannot safely delete" + ); + return Err(ConnectionError::Stuck(format!( + "disconnect failed, device in state {final_state}" + ))); + } + } + debug!("Bluetooth disconnect phase completed"); + } + } } + // Delete connection profiles (generic, works for all types) debug!("Starting connection deletion phase..."); let settings = nm_proxy( @@ -236,15 +298,17 @@ pub(crate) async fn forget(conn: &Connection, ssid: &str) -> Result<()> { let mut should_delete = false; + // Match by connection ID (works for all connection types) if let Some(conn_sec) = settings_map.get("connection") { if let Some(Value::Str(id)) = conn_sec.get("id") { - if id.as_str() == ssid { + if id.as_str() == name { should_delete = true; debug!("Found connection by ID: {id}"); } } } + // Additional WiFi-specific matching by SSID if let Some(wifi_sec) = settings_map.get("802-11-wireless") { if let Some(Value::Array(arr)) = wifi_sec.get("ssid") { let mut raw = Vec::new(); @@ -253,9 +317,19 @@ pub(crate) async fn forget(conn: &Connection, ssid: &str) -> Result<()> { raw.push(b); } } - if decode_ssid_or_empty(&raw) == ssid { + if decode_ssid_or_empty(&raw) == name { should_delete = true; - debug!("Found connection by SSID match"); + debug!("Found WiFi connection by SSID match"); + } + } + } + + // Matching by bdaddr for Bluetooth connections + if let Some(bt_sec) = settings_map.get("bluetooth") { + if let Some(Value::Str(bdaddr)) = bt_sec.get("bdaddr") { + if bdaddr.as_str() == name { + should_delete = true; + debug!("Found Bluetooth connection by bdaddr match"); } } } @@ -284,11 +358,18 @@ pub(crate) async fn forget(conn: &Connection, ssid: &str) -> Result<()> { } if deleted_count > 0 { - info!("Successfully deleted {deleted_count} connection(s) for '{ssid}'"); + info!("Successfully deleted {deleted_count} connection(s) for '{name}'"); Ok(()) } else { - debug!("No saved connections found for '{ssid}'"); - Err(ConnectionError::NoSavedConnection) + debug!("No saved connections found for '{name}'"); + + // For Bluetooth, it's normal to have no NetworkManager connection profile if the device is only paired in BlueZ. + if device_filter == Some(device_type::BLUETOOTH) { + debug!("Bluetooth device '{name}' has no NetworkManager connection profile (device may only be paired in BlueZ)"); + Ok(()) + } else { + Err(ConnectionError::NoSavedConnection) + } } } @@ -417,7 +498,7 @@ async fn ensure_disconnected( nm: &NMProxy<'_>, wifi_device: &OwnedObjectPath, ) -> Result<()> { - if let Some(active) = current_ssid(conn).await { + if let Some(active) = Wifi::current(conn).await { debug!("Disconnecting from {active}"); if let Ok(conns) = nm.active_connections().await { diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 6de84f21..12c942e8 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -7,9 +7,10 @@ use log::{debug, warn}; use zbus::Connection; -use crate::api::models::{ConnectionError, Device, DeviceIdentity, DeviceState}; +use crate::api::models::{BluetoothDevice, ConnectionError, Device, DeviceIdentity, DeviceState}; +use crate::core::bluetooth::populate_bluez_info; use crate::core::state_wait::wait_for_wifi_device_ready; -use crate::dbus::{NMDeviceProxy, NMProxy}; +use crate::dbus::{NMBluetoothProxy, NMDeviceProxy, NMProxy}; use crate::types::constants::device_type; use crate::Result; @@ -73,6 +74,18 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { } }; + // Avoiding this breaking change for now + // Get link speed for wired devices + /* let speed = if raw_type == device_type::ETHERNET { + async { + let wired = NMWiredProxy::builder(conn).path(p.clone())?.build().await?; + wired.speed().await + } + .await + .ok() + } else { + None + };*/ devices.push(Device { path: p.to_string(), interface, @@ -84,6 +97,51 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { state, managed, driver, + // speed, + }); + } + Ok(devices) +} + +pub(crate) async fn list_bluetooth_devices(conn: &Connection) -> Result> { + let proxy = NMProxy::new(conn).await?; + let paths = proxy.get_devices().await?; + + let mut devices = Vec::new(); + for p in paths { + // So we can get the device type and state + let d_proxy = NMDeviceProxy::builder(conn) + .path(p.clone())? + .build() + .await?; + + // Only process Bluetooth devices + if d_proxy.device_type().await? != device_type::BLUETOOTH { + continue; + } + // Bluetooth-specific proxy + // to get BD_ADDR and capabilities + let bd_proxy = NMBluetoothProxy::builder(conn) + .path(p.clone())? + .build() + .await?; + + let bdaddr = bd_proxy + .hw_address() + .await + .unwrap_or_else(|_| String::from("00:00:00:00:00:00")); + let bt_caps = bd_proxy.bt_capabilities().await?; + let raw_state = d_proxy.state().await?; + let state = raw_state.into(); + + let bluez_info = populate_bluez_info(conn, &bdaddr).await?; + + devices.push(BluetoothDevice { + bdaddr, + name: bluez_info.0, + alias: bluez_info.1, + bt_caps, + state, }); } Ok(devices) @@ -145,3 +203,38 @@ pub(crate) async fn wifi_enabled(conn: &Connection) -> Result { let nm = NMProxy::new(conn).await?; Ok(nm.wireless_enabled().await?) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::BluetoothNetworkRole; + + #[test] + fn test_default_bluetooth_address() { + // Test that the default address used for devices without hardware address is valid + let default_addr = "00:00:00:00:00:00"; + assert_eq!(default_addr.len(), 17); + assert_eq!(default_addr.matches(':').count(), 5); + } + + #[test] + fn test_bluetooth_device_construction() { + let panu = BluetoothNetworkRole::PanU as u32; + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("TestDevice".into()), + alias: Some("Test".into()), + bt_caps: panu, + state: DeviceState::Activated, + }; + + assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); + assert_eq!(device.name, Some("TestDevice".into())); + assert_eq!(device.alias, Some("Test".into())); + assert!(matches!(device.bt_caps, _panu)); + assert_eq!(device.state, DeviceState::Activated); + } + + // Note: Most device listing functions require a real D-Bus connection + // and NetworkManager running, so they are better suited for integration tests. +} diff --git a/nmrs/src/core/mod.rs b/nmrs/src/core/mod.rs index 70853d3d..0ed7c71e 100644 --- a/nmrs/src/core/mod.rs +++ b/nmrs/src/core/mod.rs @@ -3,6 +3,7 @@ //! This module contains the internal implementation details for managing //! network connections, devices, scanning, and state monitoring. +pub(crate) mod bluetooth; pub(crate) mod connection; pub(crate) mod connection_settings; pub(crate) mod device; diff --git a/nmrs/src/dbus/bluetooth.rs b/nmrs/src/dbus/bluetooth.rs new file mode 100644 index 00000000..80b2f6b9 --- /dev/null +++ b/nmrs/src/dbus/bluetooth.rs @@ -0,0 +1,94 @@ +//! Bluetooth Device Proxy +//! +//! This module provides D-Bus proxy interfaces for interacting with Bluetooth +//! devices through NetworkManager and BlueZ. + +use zbus::proxy; +use zbus::Result; + +/// Proxy for Bluetooth devices +/// +/// Provides access to Bluetooth-specific properties and methods through +/// NetworkManager's D-Bus interface. +/// +/// # Example +/// +/// ```ignore +/// use nmrs::dbus::NMBluetoothProxy; +/// use zbus::Connection; +/// +/// # async fn example() -> Result<(), Box> { +/// let conn = Connection::system().await?; +/// let proxy = NMBluetoothProxy::builder(&conn) +/// .path("/org/freedesktop/NetworkManager/Devices/1")? +/// .build() +/// .await?; +/// +/// let bdaddr = proxy.bd_address().await?; +/// println!("Bluetooth address: {}", bdaddr); +/// # Ok(()) +/// # } +/// ``` +#[proxy( + interface = "org.freedesktop.NetworkManager.Device.Bluetooth", + default_service = "org.freedesktop.NetworkManager" +)] +pub trait NMBluetooth { + /// Bluetooth MAC address of the device. + /// + /// Returns the BD_ADDR (Bluetooth Device Address) in the format + /// "XX:XX:XX:XX:XX:XX" where each XX is a hexadecimal value. + #[zbus(property)] + fn hw_address(&self) -> Result; + + /// Bluetooth capabilities of the device (either DUN or NAP). + /// + /// Returns a bitmask where: + /// - 0x01 = DUN (Dial-Up Networking) + /// - 0x02 = NAP (Network Access Point) + /// + /// A device may support multiple capabilities. + #[zbus(property)] + fn bt_capabilities(&self) -> Result; +} + +/// Extension trait for Bluetooth device information via BlueZ. +/// +/// Provides convenient methods to access Bluetooth-specific properties +/// that are otherwise not exposed by NetworkManager. This interfaces directly +/// with BlueZ, the Linux Bluetooth stack. +/// +/// # Example +/// +/// ```ignore +/// use nmrs::dbus::BluezDeviceExtProxy; +/// use zbus::Connection; +/// +/// # async fn example() -> Result<(), Box> { +/// let conn = Connection::system().await?; +/// let proxy = BluezDeviceExtProxy::builder(&conn) +/// .path("/org/bluez/hci0/dev_00_1A_7D_DA_71_13")? +/// .build() +/// .await?; +/// +/// let name = proxy.name().await?; +/// let alias = proxy.alias().await?; +/// println!("Device: {} ({})", alias, name); +/// # Ok(()) +/// # } +/// ``` +#[proxy(interface = "org.bluez.Device1", default_service = "org.bluez")] +pub trait BluezDeviceExt { + /// Returns the name of the Bluetooth device. + /// + /// This is typically the manufacturer-assigned name of the device. + #[zbus(property)] + fn name(&self) -> Result; + + /// Returns the alias of the Bluetooth device. + /// + /// This is typically a user-friendly name that can be customized. + /// If no alias is set, this usually returns the same value as `name()`. + #[zbus(property)] + fn alias(&self) -> Result; +} diff --git a/nmrs/src/dbus/mod.rs b/nmrs/src/dbus/mod.rs index 3cfe0b15..4627bd4c 100644 --- a/nmrs/src/dbus/mod.rs +++ b/nmrs/src/dbus/mod.rs @@ -5,12 +5,16 @@ mod access_point; mod active_connection; +mod bluetooth; mod device; mod main_nm; +mod wired; mod wireless; pub(crate) use access_point::NMAccessPointProxy; pub(crate) use active_connection::NMActiveConnectionProxy; +pub(crate) use bluetooth::{BluezDeviceExtProxy, NMBluetoothProxy}; pub(crate) use device::NMDeviceProxy; pub(crate) use main_nm::NMProxy; +pub(crate) use wired::NMWiredProxy; pub(crate) use wireless::NMWirelessProxy; diff --git a/nmrs/src/dbus/wired.rs b/nmrs/src/dbus/wired.rs index 798bfb5b..4b77202c 100644 --- a/nmrs/src/dbus/wired.rs +++ b/nmrs/src/dbus/wired.rs @@ -1,7 +1,7 @@ //! NetworkManager Wired (Ethernet) Device Proxy -use zbus::Result; use zbus::proxy; +use zbus::Result; /// Proxy for wired devices (Ethernet). /// diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index 0461dca5..e80539df 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -7,7 +7,7 @@ //! //! ## WiFi Connection //! -//! ```no_run +//! ```rust //! use nmrs::{NetworkManager, WifiSecurity}; //! //! # async fn example() -> nmrs::Result<()> { @@ -34,7 +34,7 @@ //! //! ## VPN Connection (WireGuard) //! -//! ```no_run +//! ```rust //! use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; //! //! # async fn example() -> nmrs::Result<()> { @@ -107,7 +107,7 @@ //! //! ## Connecting to Different Network Types //! -//! ```no_run +//! ```rust //! use nmrs::{NetworkManager, WifiSecurity, EapOptions, EapMethod, Phase2}; //! //! # async fn example() -> nmrs::Result<()> { @@ -146,7 +146,7 @@ //! All operations return [`Result`], which is an alias for `Result`. //! The [`ConnectionError`] type provides specific variants for different failure modes: //! -//! ```no_run +//! ```rust //! use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; //! //! # async fn example() -> nmrs::Result<()> { @@ -176,7 +176,7 @@ //! //! ## Device Management //! -//! ```no_run +//! ```rust //! use nmrs::NetworkManager; //! //! # async fn example() -> nmrs::Result<()> { @@ -203,7 +203,7 @@ //! //! Monitor network and device changes in real-time using D-Bus signals: //! -//! ```ignore +//! ```rust //! use nmrs::NetworkManager; //! //! # async fn example() -> nmrs::Result<()> { diff --git a/nmrs/src/monitoring/bluetooth.rs b/nmrs/src/monitoring/bluetooth.rs new file mode 100644 index 00000000..42f72156 --- /dev/null +++ b/nmrs/src/monitoring/bluetooth.rs @@ -0,0 +1,144 @@ +//! Bluetooth device monitoring and current connection status. +//! +//! Provides functions to retrieve information about currently connected +//! Bluetooth devices and their connection state. + +use async_trait::async_trait; +use zbus::Connection; + +use crate::dbus::{NMBluetoothProxy, NMDeviceProxy, NMProxy}; +use crate::monitoring::transport::ActiveTransport; +use crate::try_log; +use crate::types::constants::device_type; + +pub(crate) struct Bluetooth; + +#[async_trait] +impl ActiveTransport for Bluetooth { + type Output = String; + + async fn current(conn: &Connection) -> Option { + current_bluetooth_bdaddr(conn).await + } +} + +/// Returns the Bluetooth MAC address (bdaddr) of the currently connected Bluetooth device. +/// +/// Checks all Bluetooth devices for an active connection and returns +/// the MAC address. Returns `None` if not connected to any Bluetooth device. +/// +/// Uses the `try_log!` macro to gracefully handle errors without +/// propagating them, since this is often used in non-critical contexts. +/// +/// # Example +/// +/// ```ignore +/// use nmrs::monitoring::bluetooth::current_bluetooth_bdaddr; +/// use zbus::Connection; +/// +/// # async fn example() -> Result<(), Box> { +/// let conn = Connection::system().await?; +/// if let Some(bdaddr) = current_bluetooth_bdaddr(&conn).await { +/// println!("Connected to Bluetooth device: {}", bdaddr); +/// } else { +/// println!("No Bluetooth device connected"); +/// } +/// # Ok(()) +/// # } +/// ``` +pub(crate) async fn current_bluetooth_bdaddr(conn: &Connection) -> Option { + let nm = try_log!(NMProxy::new(conn).await, "Failed to create NM proxy"); + let devices = try_log!(nm.get_devices().await, "Failed to get devices"); + + for dp in devices { + let dev_builder = try_log!( + NMDeviceProxy::builder(conn).path(dp.clone()), + "Failed to create device proxy builder" + ); + let dev = try_log!(dev_builder.build().await, "Failed to build device proxy"); + + let dev_type = try_log!(dev.device_type().await, "Failed to get device type"); + if dev_type != device_type::BLUETOOTH { + continue; + } + + // Check if device is in an active/connected state + let state = try_log!(dev.state().await, "Failed to get device state"); + // State 100 = Activated (connected) + if state != 100 { + continue; + } + + // Get the Bluetooth MAC address from the Bluetooth-specific interface + let bt_builder = try_log!( + NMBluetoothProxy::builder(conn).path(dp.clone()), + "Failed to create Bluetooth proxy builder" + ); + let bt = try_log!(bt_builder.build().await, "Failed to build Bluetooth proxy"); + + if let Ok(bdaddr) = bt.hw_address().await { + return Some(bdaddr); + } + } + None +} + +/// Returns detailed information about the current Bluetooth connection. +/// +/// Similar to `current_bluetooth_bdaddr` but also returns the Bluetooth +/// capabilities (DUN or PANU) of the connected device. +/// +/// Returns `Some((bdaddr, capabilities))` if connected, `None` otherwise. +#[allow(dead_code)] +pub(crate) async fn current_bluetooth_info(conn: &Connection) -> Option<(String, u32)> { + let nm = try_log!(NMProxy::new(conn).await, "Failed to create NM proxy"); + let devices = try_log!(nm.get_devices().await, "Failed to get devices"); + + for dp in devices { + let dev_builder = try_log!( + NMDeviceProxy::builder(conn).path(dp.clone()), + "Failed to create device proxy builder" + ); + let dev = try_log!(dev_builder.build().await, "Failed to build device proxy"); + + let dev_type = try_log!(dev.device_type().await, "Failed to get device type"); + if dev_type != device_type::BLUETOOTH { + continue; + } + + // Check if device is in an active/connected state + let state = try_log!(dev.state().await, "Failed to get device state"); + // State 100 = Activated (connected) + if state != 100 { + continue; + } + + // Get the Bluetooth MAC address and capabilities + let bt_builder = try_log!( + NMBluetoothProxy::builder(conn).path(dp.clone()), + "Failed to create Bluetooth proxy builder" + ); + let bt = try_log!(bt_builder.build().await, "Failed to build Bluetooth proxy"); + + if let (Ok(bdaddr), Ok(capabilities)) = (bt.hw_address().await, bt.bt_capabilities().await) + { + return Some((bdaddr, capabilities)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bluetooth_struct_exists() { + // Verify the Bluetooth struct can be instantiated + let _bt = Bluetooth; + } + + // Most of the monitoring functions require a real D-Bus connection + // and NetworkManager running, so they are better suited for integration tests. + // We can add unit tests for helper functions if they are extracted. +} diff --git a/nmrs/src/monitoring/info.rs b/nmrs/src/monitoring/info.rs index 0ee92bd7..56bfc8f7 100644 --- a/nmrs/src/monitoring/info.rs +++ b/nmrs/src/monitoring/info.rs @@ -1,13 +1,12 @@ -//! Network information and current connection status. +//! Network information and detailed network status. //! -//! Provides functions to retrieve detailed information about networks -//! and query the current connection state. +//! Provides functions to retrieve detailed information about WiFi networks, +//! including security capabilities, signal strength, and connection details. use log::debug; use zbus::Connection; use crate::api::models::{ConnectionError, Network, NetworkInfo}; -#[allow(unused_imports)] // Used within try_log! macro use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; use crate::try_log; use crate::types::constants::{device_type, rate, security_flags}; @@ -17,7 +16,7 @@ use crate::util::utils::{ }; use crate::Result; -/// Returns detailed information about a network. +/// Returns detailed information about a WiFi network. /// /// Queries the access point for comprehensive details including: /// - BSSID (MAC address) @@ -183,6 +182,7 @@ pub(crate) async fn current_ssid(conn: &Connection) -> Option { /// /// Similar to `current_ssid` but also returns the operating frequency /// in MHz, useful for determining if connected to 2.4GHz or 5GHz band. +#[allow(dead_code)] pub(crate) async fn current_connection_info(conn: &Connection) -> Option<(String, Option)> { let nm = try_log!(NMProxy::new(conn).await, "Failed to create NM proxy"); let devices = try_log!(nm.get_devices().await, "Failed to get devices"); diff --git a/nmrs/src/monitoring/mod.rs b/nmrs/src/monitoring/mod.rs index fb2a8ffa..d5853c3f 100644 --- a/nmrs/src/monitoring/mod.rs +++ b/nmrs/src/monitoring/mod.rs @@ -3,6 +3,9 @@ //! This module provides functions for monitoring network state changes, //! device state changes, and retrieving current connection information. +pub(crate) mod bluetooth; pub(crate) mod device; pub(crate) mod info; pub(crate) mod network; +pub(crate) mod transport; +pub(crate) mod wifi; diff --git a/nmrs/src/monitoring/transport.rs b/nmrs/src/monitoring/transport.rs new file mode 100644 index 00000000..f9f5d84e --- /dev/null +++ b/nmrs/src/monitoring/transport.rs @@ -0,0 +1,9 @@ +use async_trait::async_trait; +use zbus::Connection; + +#[async_trait] +pub trait ActiveTransport { + type Output; + + async fn current(conn: &Connection) -> Option; +} diff --git a/nmrs/src/monitoring/wifi.rs b/nmrs/src/monitoring/wifi.rs new file mode 100644 index 00000000..47225389 --- /dev/null +++ b/nmrs/src/monitoring/wifi.rs @@ -0,0 +1,118 @@ +//! WiFi connection monitoring and current connection status. +//! +//! Provides functions to retrieve information about currently connected +//! WiFi networks and their connection state. + +use async_trait::async_trait; +use zbus::Connection; + +use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::monitoring::transport::ActiveTransport; +use crate::try_log; +use crate::types::constants::device_type; +use crate::util::utils::decode_ssid_or_empty; + +pub(crate) struct Wifi; + +#[async_trait] +impl ActiveTransport for Wifi { + type Output = String; + + async fn current(conn: &Connection) -> Option { + current_ssid(conn).await + } +} + +/// Returns the SSID of the currently connected Wi-Fi network. +/// +/// Checks all Wi-Fi devices for an active access point and returns +/// its SSID. Returns `None` if not connected to any Wi-Fi network. +/// +/// Uses the `try_log!` macro to gracefully handle errors without +/// propagating them, since this is often used in non-critical contexts. +pub(crate) async fn current_ssid(conn: &Connection) -> Option { + let nm = try_log!(NMProxy::new(conn).await, "Failed to create NM proxy"); + let devices = try_log!(nm.get_devices().await, "Failed to get devices"); + + for dp in devices { + let dev_builder = try_log!( + NMDeviceProxy::builder(conn).path(dp.clone()), + "Failed to create device proxy builder" + ); + let dev = try_log!(dev_builder.build().await, "Failed to build device proxy"); + + let dev_type = try_log!(dev.device_type().await, "Failed to get device type"); + if dev_type != device_type::WIFI { + continue; + } + + let wifi_builder = try_log!( + NMWirelessProxy::builder(conn).path(dp.clone()), + "Failed to create wireless proxy builder" + ); + let wifi = try_log!(wifi_builder.build().await, "Failed to build wireless proxy"); + + if let Ok(active_ap) = wifi.active_access_point().await { + if active_ap.as_str() != "/" { + let ap_builder = try_log!( + NMAccessPointProxy::builder(conn).path(active_ap), + "Failed to create access point proxy builder" + ); + let ap = try_log!( + ap_builder.build().await, + "Failed to build access point proxy" + ); + let ssid_bytes = try_log!(ap.ssid().await, "Failed to get SSID bytes"); + let ssid = decode_ssid_or_empty(&ssid_bytes); + return Some(ssid.to_string()); + } + } + } + None +} + +/// Returns the SSID and frequency of the current Wi-Fi connection. +/// +/// Similar to `current_ssid` but also returns the operating frequency +/// in MHz, useful for determining if connected to 2.4GHz or 5GHz band. +pub(crate) async fn current_connection_info(conn: &Connection) -> Option<(String, Option)> { + let nm = try_log!(NMProxy::new(conn).await, "Failed to create NM proxy"); + let devices = try_log!(nm.get_devices().await, "Failed to get devices"); + + for dp in devices { + let dev_builder = try_log!( + NMDeviceProxy::builder(conn).path(dp.clone()), + "Failed to create device proxy builder" + ); + let dev = try_log!(dev_builder.build().await, "Failed to build device proxy"); + + let dev_type = try_log!(dev.device_type().await, "Failed to get device type"); + if dev_type != device_type::WIFI { + continue; + } + + let wifi_builder = try_log!( + NMWirelessProxy::builder(conn).path(dp.clone()), + "Failed to create wireless proxy builder" + ); + let wifi = try_log!(wifi_builder.build().await, "Failed to build wireless proxy"); + + if let Ok(active_ap) = wifi.active_access_point().await { + if active_ap.as_str() != "/" { + let ap_builder = try_log!( + NMAccessPointProxy::builder(conn).path(active_ap), + "Failed to create access point proxy builder" + ); + let ap = try_log!( + ap_builder.build().await, + "Failed to build access point proxy" + ); + let ssid_bytes = try_log!(ap.ssid().await, "Failed to get SSID bytes"); + let ssid = decode_ssid_or_empty(&ssid_bytes); + let frequency = ap.frequency().await.ok(); + return Some((ssid.to_string(), frequency)); + } + } + } + None +} diff --git a/nmrs/src/types/constants.rs b/nmrs/src/types/constants.rs index 04c5d3ff..b0b35d2f 100644 --- a/nmrs/src/types/constants.rs +++ b/nmrs/src/types/constants.rs @@ -7,6 +7,7 @@ pub mod device_type { pub const ETHERNET: u32 = 1; pub const WIFI: u32 = 2; + pub const BLUETOOTH: u32 = 5; // pub const WIFI_P2P: u32 = 30; // pub const LOOPBACK: u32 = 32; } diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index b697f95a..cc15cb94 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -560,6 +560,7 @@ async fn test_device_states() { // Verify that all devices have valid states for device in &devices { // DeviceState should be one of the known states + // The struct is non-exhaustive and so we allow Other(_) match device.state { DeviceState::Unmanaged | DeviceState::Unavailable @@ -572,6 +573,9 @@ async fn test_device_states() { | DeviceState::Other(_) => { // Valid state } + _ => { + panic!("Invalid device state: {:?}", device.state); + } } } } @@ -589,14 +593,19 @@ async fn test_device_types() { // Verify that all devices have valid types for device in &devices { // DeviceType should be one of the known types + // The struct is non-exhaustive and so we allow Other(_) match device.device_type { DeviceType::Ethernet | DeviceType::Wifi + | DeviceType::Bluetooth | DeviceType::WifiP2P | DeviceType::Loopback | DeviceType::Other(_) => { // Valid type } + _ => { + panic!("Invalid device type: {:?}", device.device_type); + } } } } @@ -1030,3 +1039,193 @@ async fn test_vpn_credentials_structure() { assert_eq!(creds.dns.as_ref().unwrap().len(), 2); assert_eq!(creds.mtu, Some(1420)); } + +/// Check if Bluetooth is available +#[allow(dead_code)] +async fn has_bluetooth_device(nm: &NetworkManager) -> bool { + nm.list_bluetooth_devices() + .await + .map(|d| !d.is_empty()) + .unwrap_or(false) +} + +/// Skip tests if Bluetooth device is not available +#[allow(unused_macros)] +macro_rules! require_bluetooth { + ($nm:expr) => { + if !has_bluetooth_device($nm).await { + eprintln!("Skipping test: No Bluetooth device available"); + return; + } + }; +} + +/// Test listing Bluetooth devices +#[tokio::test] +async fn test_list_bluetooth_devices() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + let devices = nm + .list_bluetooth_devices() + .await + .expect("Failed to list Bluetooth devices"); + + // Verify device structure for Bluetooth devices + for device in &devices { + assert!( + !device.bdaddr.is_empty(), + "Bluetooth address should not be empty" + ); + eprintln!( + "Bluetooth device: {} ({}) - {}", + device.alias.as_deref().unwrap_or("unknown"), + device.bdaddr, + device.bt_caps + ); + } +} + +/// Test Bluetooth device type enum +#[test] +fn test_bluetooth_network_role() { + use nmrs::models::BluetoothNetworkRole; + + let panu = BluetoothNetworkRole::PanU; + assert_eq!(format!("{}", panu), "PANU"); + + let dun = BluetoothNetworkRole::Dun; + assert_eq!(format!("{}", dun), "DUN"); +} + +/// Test BluetoothIdentity structure +#[test] +fn test_bluetooth_identity_structure() { + use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; + + let identity = BluetoothIdentity { + bdaddr: "00:1A:7D:DA:71:13".into(), + bt_device_type: BluetoothNetworkRole::PanU, + }; + + assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); + assert!(matches!( + identity.bt_device_type, + BluetoothNetworkRole::PanU + )); +} + +/// Test BluetoothDevice structure +#[test] +fn test_bluetooth_device_structure() { + use nmrs::models::{BluetoothDevice, BluetoothNetworkRole}; + + let role = BluetoothNetworkRole::PanU as u32; + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_caps: role, + state: DeviceState::Activated, + }; + + assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); + assert_eq!(device.name, Some("MyPhone".into())); + assert_eq!(device.alias, Some("Phone".into())); + assert_eq!(device.state, DeviceState::Activated); +} + +/// Test BluetoothDevice display +#[test] +fn test_bluetooth_device_display() { + use nmrs::models::{BluetoothDevice, BluetoothNetworkRole}; + + let role = BluetoothNetworkRole::PanU as u32; + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_caps: role, + state: DeviceState::Activated, + }; + + let display = format!("{}", device); + assert!(display.contains("Phone")); + assert!(display.contains("00:1A:7D:DA:71:13")); +} + +/// Test Device::is_bluetooth method +#[tokio::test] +async fn test_device_is_bluetooth() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + let devices = nm.list_devices().await.expect("Failed to list devices"); + + for device in &devices { + if device.is_bluetooth() { + assert_eq!(device.device_type, DeviceType::Bluetooth); + eprintln!("Found Bluetooth device: {}", device.interface); + } + } +} + +/// Test Bluetooth device in all devices list +#[tokio::test] +async fn test_bluetooth_in_device_types() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + let devices = nm.list_devices().await.expect("Failed to list devices"); + + // Check if any Bluetooth devices exist + let bluetooth_devices: Vec<_> = devices + .iter() + .filter(|d| matches!(d.device_type, DeviceType::Bluetooth)) + .collect(); + + if !bluetooth_devices.is_empty() { + eprintln!("Found {} Bluetooth device(s)", bluetooth_devices.len()); + for device in bluetooth_devices { + eprintln!(" - {}: {}", device.interface, device.state); + } + } else { + eprintln!("No Bluetooth devices found (this is OK)"); + } +} + +/// Test ConnectionError::NoBluetoothDevice +#[test] +fn test_connection_error_no_bluetooth_device() { + let err = ConnectionError::NoBluetoothDevice; + assert_eq!(format!("{}", err), "Bluetooth device not found"); +} + +/// Test BluetoothNetworkRole conversion from u32 +#[test] +fn test_bluetooth_network_role_from_u32() { + use nmrs::models::BluetoothNetworkRole; + + assert!(matches!( + BluetoothNetworkRole::from(0), + BluetoothNetworkRole::PanU + )); + assert!(matches!( + BluetoothNetworkRole::from(1), + BluetoothNetworkRole::Dun + )); + // Unknown values should default to PanU + assert!(matches!( + BluetoothNetworkRole::from(999), + BluetoothNetworkRole::PanU + )); +} diff --git a/package.nix b/package.nix index 6a5fc558..af771936 100644 --- a/package.nix +++ b/package.nix @@ -19,7 +19,7 @@ rustPlatform.buildRustPackage { src = ./.; - cargoHash = "sha256-uLO0OeaMlqXDsy9j1Dj2Gutw+AmJsCgCXkSjxdbkkiY="; + cargoHash = "sha256-crqDInO2Dem/nJISUSzyjvaAbcG2it3wGxVvcHvymN4="; nativeBuildInputs = [ pkg-config