From c3f0e53482688bb4bf6d11833c5a5d3f0a3668d6 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Tue, 16 Dec 2025 16:34:04 -0500 Subject: [PATCH 01/13] fix: expose `NMWiredProxy` and propogate speed through also writes in field and display for Bluetooth device type --- nmrs/src/api/models.rs | 10 +++++++++- nmrs/src/core/connection.rs | 12 +++++++++++- nmrs/src/core/device.rs | 14 +++++++++++++- nmrs/src/dbus/mod.rs | 2 ++ nmrs/src/dbus/wired.rs | 2 +- 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index af096f41..62a2c0cb 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -396,9 +396,12 @@ pub struct NetworkInfo { /// println!(" This is a WiFi device"); /// } else if device.is_wired() { /// println!(" This is an Ethernet device"); +/// if let Some(speed) == device.speed { +/// println!(" Link speed: {speed} Mb/s"); +/// } /// } /// -/// if let Some(driver) = &device.driver { +/// if let Some(driver) == &device.driver { /// println!(" Driver: {}", driver); /// } /// } @@ -421,6 +424,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. @@ -845,6 +850,8 @@ pub enum DeviceType { WifiP2P, /// Loopback device (localhost). Loopback, + /// Bluetooth + Bluetooth, /// Unknown or unsupported device type with raw code. Other(u32), } @@ -1218,6 +1225,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})"), } } diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index f2af227d..985752c8 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -8,7 +8,7 @@ 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::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWiredProxy, NMWirelessProxy}; use crate::monitoring::info::current_ssid; use crate::types::constants::{device_state, device_type, timeouts}; use crate::util::utils::{decode_ssid_or_empty, nm_proxy}; @@ -144,6 +144,16 @@ 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(()) } diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 6de84f21..9c971f22 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -9,7 +9,7 @@ use zbus::Connection; use crate::api::models::{ConnectionError, Device, DeviceIdentity, DeviceState}; use crate::core::state_wait::wait_for_wifi_device_ready; -use crate::dbus::{NMDeviceProxy, NMProxy}; +use crate::dbus::{NMDeviceProxy, NMProxy, NMWiredProxy}; use crate::types::constants::device_type; use crate::Result; @@ -73,6 +73,17 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { } }; + // 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 +95,7 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { state, managed, driver, + speed, }); } Ok(devices) diff --git a/nmrs/src/dbus/mod.rs b/nmrs/src/dbus/mod.rs index 3cfe0b15..38ec3483 100644 --- a/nmrs/src/dbus/mod.rs +++ b/nmrs/src/dbus/mod.rs @@ -7,10 +7,12 @@ mod access_point; mod active_connection; mod device; mod main_nm; +mod wired; mod wireless; pub(crate) use access_point::NMAccessPointProxy; pub(crate) use active_connection::NMActiveConnectionProxy; 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). /// From 59dfbae7e69673a28d5bd373801792592f38f3c6 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 17 Dec 2025 23:22:28 -0500 Subject: [PATCH 02/13] feat: bluetooth device builder --- nmrs/src/api/builders/bluetooth.rs | 80 ++++++++++++++++++++++++++++++ nmrs/src/api/builders/mod.rs | 4 +- nmrs/src/api/models.rs | 26 +++++++++- nmrs/src/core/bluetooth.rs | 16 ++++++ nmrs/src/core/mod.rs | 1 + nmrs/src/dbus/bluetooth.rs | 21 ++++++++ nmrs/src/dbus/mod.rs | 2 + nmrs/tests/integration_test.rs | 1 + 8 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 nmrs/src/api/builders/bluetooth.rs create mode 100644 nmrs/src/core/bluetooth.rs create mode 100644 nmrs/src/dbus/bluetooth.rs diff --git a/nmrs/src/api/builders/bluetooth.rs b/nmrs/src/api/builders/bluetooth.rs new file mode 100644 index 00000000..bf3ff565 --- /dev/null +++ b/nmrs/src/api/builders/bluetooth.rs @@ -0,0 +1,80 @@ +//! 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::BluetoothSettings; +//! +//! let bt_settings = BluetoothSettings { +//! bdaddr: "00:1A:7D:DA:71:13".into(), +//! bt_device_type: "pan".into(), +//! }; +//! ``` + +use std::collections::HashMap; +use zvariant::Value; + +use crate::{models::BluetoothSettings, 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: &BluetoothSettings) -> HashMap<&'static str, Value<'static>> { + let mut s = HashMap::new(); + s.insert("bdaddr", Value::from(settings.bdaddr.clone())); + s.insert("type", Value::from(settings.bt_device_type.clone())); + s +} + +pub fn build_bluetooth_connection( + name: &str, + settings: &BluetoothSettings, + 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 +} diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index 320ec333..5fe59828 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -74,6 +74,7 @@ //! `AddConnection` or `AddAndActivateConnection` D-Bus methods. pub mod connection_builder; +pub mod bluetooth; pub mod vpn; pub mod wifi; pub mod wifi_builder; @@ -84,6 +85,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 62a2c0cb..6909aea4 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -396,12 +396,12 @@ pub struct NetworkInfo { /// println!(" This is a WiFi device"); /// } else if device.is_wired() { /// println!(" This is an Ethernet device"); -/// if let Some(speed) == device.speed { +/// if let Some(speed) = device.speed { /// println!(" Link speed: {speed} Mb/s"); /// } /// } /// -/// if let Some(driver) == &device.driver { +/// if let Some(driver) = &device.driver { /// println!(" Driver: {}", driver); /// } /// } @@ -837,6 +837,27 @@ pub struct VpnConnectionInfo { pub dns_servers: Vec, } +/// Bluetooth settings. +/// +/// Configuration options for Bluetooth devices managed by NetworkManager. +/// # Example +/// +/// ```rust +/// use nmrs::models::BluetoothSettings; +/// +/// let bt_settings = BluetoothSettings { +/// bdaddr: "00:1A:7D:DA:71:13".into(), +/// bt_device_type: "dun".into(), +/// }; +/// ``` +#[derive(Debug, Clone)] +pub struct BluetoothSettings { + /// Bluetooth device address (BDADDR) + pub bdaddr: String, + /// Bluetooth device type (DUN or PANU) + pub bt_device_type: String, +} + /// NetworkManager device types. /// /// Represents the type of network hardware managed by NetworkManager. @@ -1195,6 +1216,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), diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs new file mode 100644 index 00000000..993988e0 --- /dev/null +++ b/nmrs/src/core/bluetooth.rs @@ -0,0 +1,16 @@ +//! 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 crate::Result; +use zbus::Connection; + +#[allow(dead_code)] +#[warn(unused_variables)] +pub(crate) async fn connect_bluetooth(_conn: &Connection) -> Result<()> { + todo!() +} 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..ae39d058 --- /dev/null +++ b/nmrs/src/dbus/bluetooth.rs @@ -0,0 +1,21 @@ +//! Bluetooth Device Proxy + +use zbus::proxy; +use zbus::Result; + +/// Proxy for Bluetooth devices +/// +/// Provides access to Bluetooth-specific properties and methods. +#[proxy( + interface = "org.freedesktop.NetworkManager.Device.Bluetooth", + default_service = "org.freedesktop.NetworkManager" +)] +pub trait NMBluetooth { + /// Bluetooth name of device. + #[zbus(property)] + fn name(&self) -> Result; + + /// Bluetooth capabilities of the device (either DUN or NAP). + #[zbus(property)] + fn bt_capabilities(&self) -> Result; +} diff --git a/nmrs/src/dbus/mod.rs b/nmrs/src/dbus/mod.rs index 38ec3483..581ade33 100644 --- a/nmrs/src/dbus/mod.rs +++ b/nmrs/src/dbus/mod.rs @@ -5,6 +5,7 @@ mod access_point; mod active_connection; +mod bluetooth; mod device; mod main_nm; mod wired; @@ -12,6 +13,7 @@ mod wireless; pub(crate) use access_point::NMAccessPointProxy; pub(crate) use active_connection::NMActiveConnectionProxy; +// pub(crate) use bluetooth::NMBluetoothProxy; pub(crate) use device::NMDeviceProxy; pub(crate) use main_nm::NMProxy; pub(crate) use wired::NMWiredProxy; diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index b697f95a..40be75bb 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -592,6 +592,7 @@ async fn test_device_types() { match device.device_type { DeviceType::Ethernet | DeviceType::Wifi + | DeviceType::Bluetooth | DeviceType::WifiP2P | DeviceType::Loopback | DeviceType::Other(_) => { From 26d6801926d859ad3112097b795676fa2307dc12 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 19 Dec 2025 17:28:16 -0500 Subject: [PATCH 03/13] feat: add Bluetooth device connection support - Implement Bluetooth connection logic - Add BlueZ D-Bus integration for device name/alias retrieval - Create Bluetooth connection builder with PANU/DUN support - Add `ActiveTransport` trait for unified connection monitoring - Refactor WiFi monitoring functions into `wifi.rs` module - Add `BluetoothDevice`, `BluetoothIdentity` models - Implement `list_bluetooth_devices()` API - Add `bluetooth` and `bluetooth_connect` examples - Update `connection.rs` to use `ActiveTransport` trait --- Cargo.lock | 1 + nmrs/Cargo.toml | 1 + nmrs/examples/bluetooth.rs | 29 +++++ nmrs/examples/bluetooth_connect.rs | 47 ++++++++ nmrs/src/api/builders/bluetooth.rs | 19 ++-- nmrs/src/api/models.rs | 119 +++++++++++++++++++-- nmrs/src/api/network_manager.rs | 24 ++++- nmrs/src/core/bluetooth.rs | 166 ++++++++++++++++++++++++++++- nmrs/src/core/connection.rs | 7 +- nmrs/src/core/device.rs | 36 ++++++- nmrs/src/dbus/bluetooth.rs | 18 +++- nmrs/src/dbus/mod.rs | 2 +- nmrs/src/monitoring/bluetooth.rs | 129 ++++++++++++++++++++++ nmrs/src/monitoring/info.rs | 14 ++- nmrs/src/monitoring/mod.rs | 3 + nmrs/src/monitoring/transport.rs | 9 ++ nmrs/src/monitoring/wifi.rs | 118 ++++++++++++++++++++ nmrs/src/types/constants.rs | 1 + 18 files changed, 706 insertions(+), 37 deletions(-) create mode 100644 nmrs/examples/bluetooth.rs create mode 100644 nmrs/examples/bluetooth_connect.rs create mode 100644 nmrs/src/monitoring/bluetooth.rs create mode 100644 nmrs/src/monitoring/transport.rs create mode 100644 nmrs/src/monitoring/wifi.rs diff --git a/Cargo.lock b/Cargo.lock index d63c201d..0c26504e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -940,6 +940,7 @@ dependencies = [ name = "nmrs" version = "1.2.0" dependencies = [ + "async-trait", "futures", "futures-timer", "log", diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index c55c5ef5..9ff376a6 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -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..032b05c5 --- /dev/null +++ b/nmrs/examples/bluetooth.rs @@ -0,0 +1,29 @@ +use nmrs::{ + models::{BluetoothIdentity, BluetoothNetworkRole}, + 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?; + + let mut bucket = Vec::new(); + // List bluetooth devices + for d in devices { + println!("{d}"); + bucket.push(d); + nm.connect_bluetooth( + "unknown", + &BluetoothIdentity { + bdaddr: "00:00:00:00:00".into(), + bt_device_type: BluetoothNetworkRole::Dun, + }, + ) + .await?; + } + + Ok(()) +} diff --git a/nmrs/examples/bluetooth_connect.rs b/nmrs/examples/bluetooth_connect.rs new file mode 100644 index 00000000..6b6d84ef --- /dev/null +++ b/nmrs/examples/bluetooth_connect.rs @@ -0,0 +1,47 @@ +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(()); + } + + println!("\nAvailable Bluetooth devices:"); + for (i, device) in devices.iter().enumerate() { + println!(" {}. {}", i + 1, device); + } + + // Example: Connect to the first device + if let Some(device) = devices.first { + println!("\nConnecting to: {}", device); + + let settings = BluetoothIdentity { + bdaddr: device.bdaddr.clone(), + bt_device_type: device.bt_device_type.clone(), + }; + + 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), + } + } + + Ok(()) +} diff --git a/nmrs/src/api/builders/bluetooth.rs b/nmrs/src/api/builders/bluetooth.rs index bf3ff565..3ee9de24 100644 --- a/nmrs/src/api/builders/bluetooth.rs +++ b/nmrs/src/api/builders/bluetooth.rs @@ -14,9 +14,9 @@ //! //! ```rust //! use nmrs::builders::build_bluetooth_connection; -//! use nmrs::models::BluetoothSettings; +//! use nmrs::models::BluetoothIdentity; //! -//! let bt_settings = BluetoothSettings { +//! let bt_settings = BluetoothIdentity { //! bdaddr: "00:1A:7D:DA:71:13".into(), //! bt_device_type: "pan".into(), //! }; @@ -25,7 +25,10 @@ use std::collections::HashMap; use zvariant::Value; -use crate::{models::BluetoothSettings, ConnectionOptions}; +use crate::{ + models::{BluetoothIdentity, BluetoothNetworkRole}, + ConnectionOptions, +}; /// Builds the `connection` section with type, id, uuid, and autoconnect settings. pub fn base_connection_section( @@ -50,16 +53,20 @@ pub fn base_connection_section( } /// Builds a Bluetooth connection settings dictionary. -fn bluetooth_section(settings: &BluetoothSettings) -> HashMap<&'static str, Value<'static>> { +fn bluetooth_section(settings: &BluetoothIdentity) -> HashMap<&'static str, Value<'static>> { let mut s = HashMap::new(); s.insert("bdaddr", Value::from(settings.bdaddr.clone())); - s.insert("type", Value::from(settings.bt_device_type.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: &BluetoothSettings, + 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(); diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 6909aea4..71d15619 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -837,25 +837,69 @@ pub struct VpnConnectionInfo { pub dns_servers: Vec, } -/// Bluetooth settings. +/// Bluetooth network role. +/// +/// Specifies the role of the Bluetooth device in the network connection. +#[derive(Debug, Clone)] +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. /// -/// Configuration options for Bluetooth devices managed by NetworkManager. /// # Example /// /// ```rust -/// use nmrs::models::BluetoothSettings; +/// use nmrs::models::{BluetoothDevice, BluetoothNetworkRole, DeviceState}; /// -/// let bt_settings = BluetoothSettings { +/// let bt_device = BluetoothDevice { /// bdaddr: "00:1A:7D:DA:71:13".into(), -/// bt_device_type: "dun".into(), +/// name: Some("Foo".into()), +/// alias: Some("Bar".into()), +/// bt_device_type: BluetoothNetworkRole::PanU, +/// state: DeviceState::Activated, /// }; /// ``` #[derive(Debug, Clone)] -pub struct BluetoothSettings { - /// Bluetooth device address (BDADDR) +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_device_type: String, + pub bt_device_type: BluetoothNetworkRole, + /// Current device state + pub state: DeviceState, } /// NetworkManager device types. @@ -912,6 +956,51 @@ 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 { + write!( + f, + "{} ({}) [{}]", + self.alias.as_deref().unwrap_or("unknown"), + self.bt_device_type, + 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. @@ -1061,6 +1150,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. @@ -1269,6 +1362,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 { diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index aa9a2ca2..2d4f3621 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -1,15 +1,21 @@ use zbus::Connection; use crate::api::models::{Device, Network, NetworkInfo, WifiSecurity}; +use crate::core::bluetooth::connect_bluetooth; use crate::core::connection::{connect, connect_wired, forget}; 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::Result; /// High-level interface to NetworkManager over D-Bus. @@ -118,6 +124,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 +170,13 @@ impl NetworkManager { connect_wired(&self.conn).await } + /// Connects to a bluetooth device using the provided identity. + /// + /// # Example + 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 diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 993988e0..9c972768 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -6,11 +6,167 @@ //! Similar to other device types, it handles scanning, connecting, and monitoring //! Bluetooth devices using NetworkManager's D-Bus API. -use crate::Result; +use log::debug; use zbus::Connection; +use zvariant::OwnedObjectPath; -#[allow(dead_code)] -#[warn(unused_variables)] -pub(crate) async fn connect_bluetooth(_conn: &Connection) -> Result<()> { - todo!() +use crate::builders::bluetooth; +use crate::core::connection_settings::get_saved_connection_path; +use crate::dbus::{BluezDeviceExtProxy, NMDeviceProxy}; +use crate::monitoring::bluetooth::Bluetooth; +use crate::monitoring::transport::ActiveTransport; +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. + 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)), + } +} + +/// 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 + let bt_device = find_bluetooth_device(conn, &nm).await?; + debug!("Found Bluetooth adapter: {}", bt_device.as_str()); + + // 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); + + let (_, active_conn) = nm + .add_and_activate_connection( + connection_settings, + bt_device.clone(), + specific_object, + ) + .await?; + + crate::core::state_wait::wait_for_connection_activation(conn, &active_conn).await?; + } + } + + log::info!("Successfully connected to Bluetooth device '{name}'"); + Ok(()) +} + +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) } diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index 985752c8..76f843db 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -9,7 +9,8 @@ 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, NMWiredProxy, NMWirelessProxy}; -use crate::monitoring::info::current_ssid; +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()"); @@ -427,7 +428,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 9c971f22..53ee86de 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -7,7 +7,8 @@ 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, NMWiredProxy}; use crate::types::constants::device_type; @@ -101,6 +102,39 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { 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 { + let d_proxy = NMDeviceProxy::builder(conn) + .path(p.clone())? + .build() + .await?; + + let bdaddr = d_proxy + .hw_address() + .await + .unwrap_or_else(|_| String::from("00:00:00:00:00:00")); + let raw_bt_device_type = d_proxy.device_type().await?; + let bt_device_type = raw_bt_device_type.into(); + 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_device_type, + state, + }); + } + Ok(devices) +} + /// Waits for a Wi-Fi device to become ready for operations. /// /// Uses D-Bus signals to efficiently wait until a Wi-Fi device reaches diff --git a/nmrs/src/dbus/bluetooth.rs b/nmrs/src/dbus/bluetooth.rs index ae39d058..ae205598 100644 --- a/nmrs/src/dbus/bluetooth.rs +++ b/nmrs/src/dbus/bluetooth.rs @@ -11,11 +11,25 @@ use zbus::Result; default_service = "org.freedesktop.NetworkManager" )] pub trait NMBluetooth { - /// Bluetooth name of device. + /// Bluetooth MAC address of the device. #[zbus(property)] - fn name(&self) -> Result; + fn bd_address(&self) -> Result; /// Bluetooth capabilities of the device (either DUN or NAP). #[zbus(property)] fn bt_capabilities(&self) -> Result; } + +/// Extension trait for Bluetooth device information via BlueZ. +/// Provides convenient methods to access Bluetooth-specific properties otherwise +/// not exposed by NetworkManager. +#[proxy(interface = "org.bluez.Device1", default_service = "org.bluez")] +pub trait BluezDeviceExt { + /// Returns the name of the Bluetooth device. + #[zbus(property)] + fn name(&self) -> Result; + + /// Returns the alias of the Bluetooth device. + #[zbus(property)] + fn alias(&self) -> Result; +} diff --git a/nmrs/src/dbus/mod.rs b/nmrs/src/dbus/mod.rs index 581ade33..4627bd4c 100644 --- a/nmrs/src/dbus/mod.rs +++ b/nmrs/src/dbus/mod.rs @@ -13,7 +13,7 @@ mod wireless; pub(crate) use access_point::NMAccessPointProxy; pub(crate) use active_connection::NMActiveConnectionProxy; -// pub(crate) use bluetooth::NMBluetoothProxy; +pub(crate) use bluetooth::{BluezDeviceExtProxy, NMBluetoothProxy}; pub(crate) use device::NMDeviceProxy; pub(crate) use main_nm::NMProxy; pub(crate) use wired::NMWiredProxy; diff --git a/nmrs/src/monitoring/bluetooth.rs b/nmrs/src/monitoring/bluetooth.rs new file mode 100644 index 00000000..0fc0b9da --- /dev/null +++ b/nmrs/src/monitoring/bluetooth.rs @@ -0,0 +1,129 @@ +//! 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 +/// +/// ```no_run +/// 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.bd_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.bd_address().await, bt.bt_capabilities().await) + { + return Some((bdaddr, capabilities)); + } + } + None +} diff --git a/nmrs/src/monitoring/info.rs b/nmrs/src/monitoring/info.rs index 0ee92bd7..09aa54ff 100644 --- a/nmrs/src/monitoring/info.rs +++ b/nmrs/src/monitoring/info.rs @@ -1,23 +1,21 @@ -//! 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}; +use crate::monitoring::wifi::current_ssid; +use crate::types::constants::{rate, security_flags}; use crate::util::utils::{ bars_from_strength, channel_from_freq, decode_ssid_or_empty, for_each_access_point, mode_to_string, strength_or_zero, }; 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) 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..abf7b838 --- /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); + } + } + } + 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, 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; } From 0f5006ac1f467f7341c88f80ab8c344255fe94fb Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sat, 20 Dec 2025 01:00:48 -0500 Subject: [PATCH 04/13] fix: default object path to "/" for bluetooth device Also clean up docs. --- nmrs/examples/bluetooth.rs | 12 +----------- nmrs/examples/bluetooth_connect.rs | 5 +++-- nmrs/examples/vpn_connect.rs | 1 + nmrs/examples/wifi_scan.rs | 1 + nmrs/src/api/builders/bluetooth.rs | 4 ++-- nmrs/src/api/builders/mod.rs | 13 ++++--------- nmrs/src/core/bluetooth.rs | 28 +++++++++------------------- nmrs/src/lib.rs | 12 ++++++------ nmrs/src/monitoring/bluetooth.rs | 2 +- 9 files changed, 28 insertions(+), 50 deletions(-) diff --git a/nmrs/examples/bluetooth.rs b/nmrs/examples/bluetooth.rs index 032b05c5..9dff8279 100644 --- a/nmrs/examples/bluetooth.rs +++ b/nmrs/examples/bluetooth.rs @@ -1,5 +1,5 @@ +/// List Bluetooth devices using NetworkManager use nmrs::{ - models::{BluetoothIdentity, BluetoothNetworkRole}, NetworkManager, Result, }; @@ -10,19 +10,9 @@ async fn main() -> Result<()> { println!("Scanning for Bluetooth devices..."); let devices = nm.list_bluetooth_devices().await?; - let mut bucket = Vec::new(); // List bluetooth devices for d in devices { println!("{d}"); - bucket.push(d); - nm.connect_bluetooth( - "unknown", - &BluetoothIdentity { - bdaddr: "00:00:00:00:00".into(), - bt_device_type: BluetoothNetworkRole::Dun, - }, - ) - .await?; } Ok(()) diff --git a/nmrs/examples/bluetooth_connect.rs b/nmrs/examples/bluetooth_connect.rs index 6b6d84ef..92b56eca 100644 --- a/nmrs/examples/bluetooth_connect.rs +++ b/nmrs/examples/bluetooth_connect.rs @@ -1,3 +1,4 @@ +/// Connect to a Bluetooth device using NetworkManager. use nmrs::models::BluetoothIdentity; use nmrs::{NetworkManager, Result}; @@ -21,8 +22,8 @@ async fn main() -> Result<()> { println!(" {}. {}", i + 1, device); } - // Example: Connect to the first device - if let Some(device) = devices.first { + // Example: Connect to the fourth device + if let Some(device) = devices.get(3) { println!("\nConnecting to: {}", device); let settings = BluetoothIdentity { 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 index 3ee9de24..943c0c7f 100644 --- a/nmrs/src/api/builders/bluetooth.rs +++ b/nmrs/src/api/builders/bluetooth.rs @@ -14,11 +14,11 @@ //! //! ```rust //! use nmrs::builders::build_bluetooth_connection; -//! use nmrs::models::BluetoothIdentity; +//! use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; //! //! let bt_settings = BluetoothIdentity { //! bdaddr: "00:1A:7D:DA:71:13".into(), -//! bt_device_type: "pan".into(), +//! bt_device_type: BluetoothNetworkRole::PanU, //! }; //! ``` diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index 5fe59828..5c83cf1e 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, @@ -34,14 +34,9 @@ //! &WifiSecurity::WpaPsk { psk: "password".into() }, //! &opts //! ); -//! +//! //! // 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, diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 9c972768..93728181 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -12,10 +12,9 @@ use zvariant::OwnedObjectPath; use crate::builders::bluetooth; use crate::core::connection_settings::get_saved_connection_path; -use crate::dbus::{BluezDeviceExtProxy, NMDeviceProxy}; +use crate::dbus::{BluezDeviceExtProxy}; use crate::monitoring::bluetooth::Bluetooth; use crate::monitoring::transport::ActiveTransport; -use crate::types::constants::device_type; use crate::ConnectionError; use crate::{dbus::NMProxy, models::BluetoothIdentity, Result}; @@ -34,6 +33,7 @@ pub(crate) async fn populate_bluez_info( ) -> 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) @@ -103,8 +103,11 @@ pub(crate) async fn connect_bluetooth( } // Find the Bluetooth hardware adapter - let bt_device = find_bluetooth_device(conn, &nm).await?; - debug!("Found Bluetooth adapter: {}", bt_device.as_str()); + // Note: Unlike WiFi, Bluetooth connections in NetworkManager don't require + // specifying a specific device. We use "/" to let NetworkManager auto-select. + let bt_device = OwnedObjectPath::try_from("/") + .map_err(|e| ConnectionError::InvalidAddress(format!("Invalid device path: {}", e)))?; + debug!("Using auto-select device path for Bluetooth connection"); // Check for saved connection let saved = get_saved_connection_path(conn, name).await?; @@ -140,6 +143,8 @@ pub(crate) async fn connect_bluetooth( 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, @@ -155,18 +160,3 @@ pub(crate) async fn connect_bluetooth( log::info!("Successfully connected to Bluetooth device '{name}'"); Ok(()) } - -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) -} 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 index 0fc0b9da..dc98fea7 100644 --- a/nmrs/src/monitoring/bluetooth.rs +++ b/nmrs/src/monitoring/bluetooth.rs @@ -32,7 +32,7 @@ impl ActiveTransport for Bluetooth { /// /// # Example /// -/// ```no_run +/// ```ignore /// use nmrs::monitoring::bluetooth::current_bluetooth_bdaddr; /// use zbus::Connection; /// From b9b176ccf50c937b95f3873c3b57ee3872dc11d3 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sat, 20 Dec 2025 01:03:17 -0500 Subject: [PATCH 05/13] chore: revert breaking change to `Device` struct --- nmrs/examples/bluetooth.rs | 4 +--- nmrs/src/api/builders/mod.rs | 2 +- nmrs/src/api/models.rs | 4 ++-- nmrs/src/core/bluetooth.rs | 7 +++++-- nmrs/src/core/device.rs | 9 +++++---- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/nmrs/examples/bluetooth.rs b/nmrs/examples/bluetooth.rs index 9dff8279..3d53a222 100644 --- a/nmrs/examples/bluetooth.rs +++ b/nmrs/examples/bluetooth.rs @@ -1,7 +1,5 @@ /// List Bluetooth devices using NetworkManager -use nmrs::{ - NetworkManager, Result, -}; +use nmrs::{NetworkManager, Result}; #[tokio::main] async fn main() -> Result<()> { diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index 5c83cf1e..d74f42c5 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -34,7 +34,7 @@ //! &WifiSecurity::WpaPsk { psk: "password".into() }, //! &opts //! ); -//! +//! //! // Build Ethernet connection settings //! let eth_settings = build_ethernet_connection("eth0", &opts); //! // Build WireGuard VPN connection settings diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 71d15619..2d5d0535 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -424,8 +424,8 @@ pub struct Device { pub managed: Option, /// Kernel driver name pub driver: Option, - /// Link speed in Mb/s (wired devices) - pub speed: Option, + // Link speed in Mb/s (wired devices) + // pub speed: Option, } /// Represents the hardware identity of a network device. diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 93728181..eb9f3711 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -12,7 +12,7 @@ use zvariant::OwnedObjectPath; use crate::builders::bluetooth; use crate::core::connection_settings::get_saved_connection_path; -use crate::dbus::{BluezDeviceExtProxy}; +use crate::dbus::BluezDeviceExtProxy; use crate::monitoring::bluetooth::Bluetooth; use crate::monitoring::transport::ActiveTransport; use crate::ConnectionError; @@ -143,7 +143,10 @@ pub(crate) async fn connect_bluetooth( let connection_settings = bluetooth::build_bluetooth_connection(name, settings, &opts); - println!("Creating Bluetooth connection with settings: {:#?}", connection_settings); + println!( + "Creating Bluetooth connection with settings: {:#?}", + connection_settings + ); let (_, active_conn) = nm .add_and_activate_connection( diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 53ee86de..605980bd 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -10,7 +10,7 @@ use zbus::Connection; 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, NMWiredProxy}; +use crate::dbus::{NMDeviceProxy, NMProxy}; use crate::types::constants::device_type; use crate::Result; @@ -74,8 +74,9 @@ 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 { + /* let speed = if raw_type == device_type::ETHERNET { async { let wired = NMWiredProxy::builder(conn).path(p.clone())?.build().await?; wired.speed().await @@ -84,7 +85,7 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { .ok() } else { None - }; + };*/ devices.push(Device { path: p.to_string(), interface, @@ -96,7 +97,7 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { state, managed, driver, - speed, + // speed, }); } Ok(devices) From 153c3e29354cb3eaef73216e7421deeada5999b9 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sat, 20 Dec 2025 01:06:21 -0500 Subject: [PATCH 06/13] chore: update nix flake --- package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.nix b/package.nix index 6a5fc558..be90b1f6 100644 --- a/package.nix +++ b/package.nix @@ -19,7 +19,7 @@ rustPlatform.buildRustPackage { src = ./.; - cargoHash = "sha256-uLO0OeaMlqXDsy9j1Dj2Gutw+AmJsCgCXkSjxdbkkiY="; + cargoHash = "sha256-hmkUlcw/D38ASqo7GcNK8p6IqfnFIAQWRrjpWymzKFY="; nativeBuildInputs = [ pkg-config From f2383b15cd266440be02e9cf8735cc7ff0181e4e Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sat, 20 Dec 2025 01:27:19 -0500 Subject: [PATCH 07/13] feat: unit + integration tests for Bluetooth device components This commit also marks various structs/enums as `non_exhaustive` so as to avoid breaking the public API --- README.md | 4 +- nmrs/src/api/builders/bluetooth.rs | 231 +++++++++++++++++++++++++++++ nmrs/src/api/models.rs | 137 ++++++++++++++++- nmrs/src/core/bluetooth.rs | 46 ++++++ nmrs/src/core/device.rs | 44 +++++- nmrs/src/dbus/bluetooth.rs | 65 +++++++- nmrs/src/monitoring/bluetooth.rs | 15 ++ nmrs/tests/integration_test.rs | 188 +++++++++++++++++++++++ 8 files changed, 719 insertions(+), 11 deletions(-) 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/src/api/builders/bluetooth.rs b/nmrs/src/api/builders/bluetooth.rs index 943c0c7f..030b133e 100644 --- a/nmrs/src/api/builders/bluetooth.rs +++ b/nmrs/src/api/builders/bluetooth.rs @@ -85,3 +85,234 @@ pub fn build_bluetooth_connection( 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/models.rs b/nmrs/src/api/models.rs index 2d5d0535..08a84e5e 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -396,9 +396,8 @@ pub struct NetworkInfo { /// println!(" This is a WiFi device"); /// } else if device.is_wired() { /// println!(" This is an Ethernet device"); -/// if let Some(speed) = device.speed { -/// println!(" Link speed: {speed} Mb/s"); -/// } +/// } else if device.is_bluetooth() { +/// println!(" This is a Bluetooth device"); /// } /// /// if let Some(driver) = &device.driver { @@ -840,7 +839,13 @@ pub struct VpnConnectionInfo { /// Bluetooth network role. /// /// Specifies the role of the Bluetooth device in the network connection. -#[derive(Debug, Clone)] +/// +/// # 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 @@ -1770,4 +1775,128 @@ 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 device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_device_type: BluetoothNetworkRole::PanU, + 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_device_type, BluetoothNetworkRole::PanU)); + assert_eq!(device.state, DeviceState::Activated); + } + + #[test] + fn test_bluetooth_device_display() { + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_device_type: BluetoothNetworkRole::PanU, + 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 device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: None, + bt_device_type: BluetoothNetworkRole::Dun, + 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/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index eb9f3711..6d2e71c3 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -163,3 +163,49 @@ pub(crate) async fn connect_bluetooth( log::info!("Successfully connected to Bluetooth device '{name}'"); 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/device.rs b/nmrs/src/core/device.rs index 605980bd..ce36baa1 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -114,12 +114,18 @@ pub(crate) async fn list_bluetooth_devices(conn: &Connection) -> Result 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 device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("TestDevice".into()), + alias: Some("Test".into()), + bt_device_type: BluetoothNetworkRole::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_device_type, BluetoothNetworkRole::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/dbus/bluetooth.rs b/nmrs/src/dbus/bluetooth.rs index ae205598..17ed3fd6 100644 --- a/nmrs/src/dbus/bluetooth.rs +++ b/nmrs/src/dbus/bluetooth.rs @@ -1,35 +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. +/// 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 bd_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 otherwise -/// not exposed by NetworkManager. +/// +/// 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/monitoring/bluetooth.rs b/nmrs/src/monitoring/bluetooth.rs index dc98fea7..3fc250c1 100644 --- a/nmrs/src/monitoring/bluetooth.rs +++ b/nmrs/src/monitoring/bluetooth.rs @@ -127,3 +127,18 @@ pub(crate) async fn current_bluetooth_info(conn: &Connection) -> Option<(String, } 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/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 40be75bb..9a64dc7b 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -1031,3 +1031,191 @@ 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_device_type + ); + } +} + +/// 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 device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_device_type: BluetoothNetworkRole::PanU, + 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 device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_device_type: BluetoothNetworkRole::PanU, + 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 + )); +} From 48c4e90b873b826eae0a45976e0c8289e048a2ee Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sat, 20 Dec 2025 18:47:43 -0500 Subject: [PATCH 08/13] chore: mark more enums `#[non_exhaustive]` --- nmrs/src/api/models.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 08a84e5e..2c4115ae 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. @@ -910,6 +913,7 @@ pub struct BluetoothDevice { /// NetworkManager device types. /// /// Represents the type of network hardware managed by NetworkManager. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq)] pub enum DeviceType { /// Wired Ethernet device. @@ -929,6 +933,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. @@ -1066,6 +1071,7 @@ impl Display for BluetoothNetworkRole { /// # Ok(()) /// # } /// ``` +#[non_exhaustive] #[derive(Debug, Error)] pub enum ConnectionError { /// A D-Bus communication error occurred. From 766e211962e38e81c4a26ef0b604c088d073a088 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 22 Dec 2025 15:57:03 -0500 Subject: [PATCH 09/13] fix: proxy field name for `bd_addr` replaced with correct `hw_addr` - We now list bluetooth devices based on their capabilities, which requires us to proxy the Bluetooth interface of course - Refactored `forget()` to target more than just wifi devices, as with the case in bluetooth devices, we dont actually need all the fields wifi does - Changed the `BluetoothDevice` model to take `bt_caps` instead of the `bt_device_type` which is canonical with what NM expects from Bluetooth devices (since we can just grab the type whenever we want via `DeviceType` in `NMDeviceProxy`) - Other pedantic changes to tests, and docs due to changes above --- Cargo.lock | 101 +++++++++++++++++++ nmrs/Cargo.toml | 2 +- nmrs/examples/bluetooth_connect.rs | 21 ++-- nmrs/src/api/models.rs | 19 ++-- nmrs/src/api/network_manager.rs | 32 +++++- nmrs/src/core/bluetooth.rs | 68 ++++++++++++- nmrs/src/core/connection.rs | 152 +++++++++++++++++++++-------- nmrs/src/core/device.rs | 24 +++-- nmrs/src/dbus/bluetooth.rs | 2 +- nmrs/src/monitoring/bluetooth.rs | 4 +- nmrs/tests/integration_test.rs | 16 ++- 11 files changed, 363 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c26504e..6c8800c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.21" @@ -380,6 +389,29 @@ dependencies = [ "syn", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -862,6 +894,30 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "jiff" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -941,6 +997,7 @@ name = "nmrs" version = "1.2.0" dependencies = [ "async-trait", + "env_logger", "futures", "futures-timer", "log", @@ -1091,6 +1148,21 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "portable-atomic" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1144,6 +1216,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "rustc_version" version = "0.4.1" diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index 9ff376a6..56a88fda 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -21,7 +21,7 @@ thiserror.workspace = true uuid.workspace = true futures.workspace = true futures-timer.workspace = true -async-trait = "0.1.89" +async-trait.workspace = true [dev-dependencies] tokio.workspace = true diff --git a/nmrs/examples/bluetooth_connect.rs b/nmrs/examples/bluetooth_connect.rs index 92b56eca..c07eb541 100644 --- a/nmrs/examples/bluetooth_connect.rs +++ b/nmrs/examples/bluetooth_connect.rs @@ -1,7 +1,6 @@ /// 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?; @@ -17,18 +16,20 @@ async fn main() -> Result<()> { 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); } - // Example: Connect to the fourth device - if let Some(device) = devices.get(3) { + // 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_device_type.clone(), + bt_device_type: device.bt_caps.into(), }; let name = device @@ -39,9 +40,17 @@ async fn main() -> Result<()> { .unwrap_or("Bluetooth Device"); match nm.connect_bluetooth(name, &settings).await { - Ok(_) => println!("✓ Successfully connected to {}", name), - Err(e) => eprintln!("✗ Failed to connect: {}", e), + 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/src/api/models.rs b/nmrs/src/api/models.rs index 2c4115ae..531323e3 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -888,11 +888,12 @@ pub struct BluetoothIdentity { /// ```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_device_type: BluetoothNetworkRole::PanU, +/// bt_caps: role, /// state: DeviceState::Activated, /// }; /// ``` @@ -905,7 +906,7 @@ pub struct BluetoothDevice { /// Device alias from BlueZ pub alias: Option, /// Bluetooth device type (DUN or PANU) - pub bt_device_type: BluetoothNetworkRole, + pub bt_caps: u32, /// Current device state pub state: DeviceState, } @@ -991,11 +992,12 @@ impl Display for Device { /// 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"), - self.bt_device_type, + role, self.bdaddr ) } @@ -1823,28 +1825,30 @@ mod tests { #[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_device_type: BluetoothNetworkRole::PanU, + 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_device_type, BluetoothNetworkRole::PanU)); + 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_device_type: BluetoothNetworkRole::PanU, + bt_caps: role, state: DeviceState::Activated, }; @@ -1856,11 +1860,12 @@ mod tests { #[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_device_type: BluetoothNetworkRole::Dun, + bt_caps: role, state: DeviceState::Disconnected, }; diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 2d4f3621..1e273e1e 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -2,7 +2,7 @@ use zbus::Connection; use crate::api::models::{Device, Network, NetworkInfo, WifiSecurity}; use crate::core::bluetooth::connect_bluetooth; -use crate::core::connection::{connect, connect_wired, forget}; +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_bluetooth_devices, list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled, @@ -16,6 +16,7 @@ use crate::monitoring::device as device_monitor; 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. @@ -379,11 +380,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 index 6d2e71c3..515d3ae0 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -9,12 +9,16 @@ 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::dbus::BluezDeviceExtProxy; +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}; @@ -50,6 +54,24 @@ pub(crate) async fn populate_bluez_info( } } +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: @@ -105,8 +127,7 @@ pub(crate) async fn connect_bluetooth( // 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 = OwnedObjectPath::try_from("/") - .map_err(|e| ConnectionError::InvalidAddress(format!("Invalid device path: {}", e)))?; + let bt_device = find_bluetooth_device(conn, &nm).await?; debug!("Using auto-select device path for Bluetooth connection"); // Check for saved connection @@ -156,7 +177,7 @@ pub(crate) async fn connect_bluetooth( ) .await?; - crate::core::state_wait::wait_for_connection_activation(conn, &active_conn).await?; + wait_for_connection_activation(conn, &active_conn).await?; } } @@ -164,6 +185,45 @@ pub(crate) async fn connect_bluetooth( 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::*; diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index 76f843db..767d8f0d 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -159,66 +159,117 @@ pub(crate) async fn connect_wired(conn: &Connection) -> Result<()> { Ok(()) } -/// Forgets (deletes) all saved connections for a network. +/// Generic function to forget (delete) connections by name and optionally by device type. /// -/// 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. +/// This handles disconnection if currently active, then deletes the connection profile(s). +/// Can be used for WiFi, Bluetooth, or any NetworkManager connection type. /// +/// # Arguments +/// +/// * `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( @@ -247,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(); @@ -264,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"); } } } @@ -295,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) + } } } diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index ce36baa1..12c942e8 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -10,7 +10,7 @@ use zbus::Connection; 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; @@ -109,23 +109,28 @@ pub(crate) async fn list_bluetooth_devices(conn: &Connection) -> Result Result Result; + fn hw_address(&self) -> Result; /// Bluetooth capabilities of the device (either DUN or NAP). /// diff --git a/nmrs/src/monitoring/bluetooth.rs b/nmrs/src/monitoring/bluetooth.rs index 3fc250c1..42f72156 100644 --- a/nmrs/src/monitoring/bluetooth.rs +++ b/nmrs/src/monitoring/bluetooth.rs @@ -76,7 +76,7 @@ pub(crate) async fn current_bluetooth_bdaddr(conn: &Connection) -> Option Option<(String, ); let bt = try_log!(bt_builder.build().await, "Failed to build Bluetooth proxy"); - if let (Ok(bdaddr), Ok(capabilities)) = (bt.bd_address().await, bt.bt_capabilities().await) + if let (Ok(bdaddr), Ok(capabilities)) = (bt.hw_address().await, bt.bt_capabilities().await) { return Some((bdaddr, capabilities)); } diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 9a64dc7b..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,6 +593,7 @@ 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 @@ -598,6 +603,9 @@ async fn test_device_types() { | DeviceType::Other(_) => { // Valid type } + _ => { + panic!("Invalid device type: {:?}", device.device_type); + } } } } @@ -1076,7 +1084,7 @@ async fn test_list_bluetooth_devices() { "Bluetooth device: {} ({}) - {}", device.alias.as_deref().unwrap_or("unknown"), device.bdaddr, - device.bt_device_type + device.bt_caps ); } } @@ -1115,11 +1123,12 @@ fn test_bluetooth_identity_structure() { 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_device_type: BluetoothNetworkRole::PanU, + bt_caps: role, state: DeviceState::Activated, }; @@ -1134,11 +1143,12 @@ fn test_bluetooth_device_structure() { 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_device_type: BluetoothNetworkRole::PanU, + bt_caps: role, state: DeviceState::Activated, }; From f5dbee1342a56d20050903d287bc2dc0a3554b96 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 22 Dec 2025 15:58:56 -0500 Subject: [PATCH 10/13] chore: bump cargo version to `2.0.0-dev` --- Cargo.lock | 103 +------------------------------------------- nmrs-gui/Cargo.toml | 2 +- nmrs/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c8800c5..2276ab71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "anstream" version = "0.6.21" @@ -389,29 +380,6 @@ dependencies = [ "syn", ] -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -894,30 +862,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "jiff" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "js-sys" version = "0.3.81" @@ -994,10 +938,9 @@ dependencies = [ [[package]] name = "nmrs" -version = "1.2.0" +version = "2.0.0-dev" dependencies = [ "async-trait", - "env_logger", "futures", "futures-timer", "log", @@ -1148,21 +1091,6 @@ dependencies = [ "windows-sys 0.61.0", ] -[[package]] -name = "portable-atomic" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" - -[[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1216,35 +1144,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - [[package]] name = "rustc_version" version = "0.4.1" 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 56a88fda..9ff376a6 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -21,7 +21,7 @@ thiserror.workspace = true uuid.workspace = true futures.workspace = true futures-timer.workspace = true -async-trait.workspace = true +async-trait = "0.1.89" [dev-dependencies] tokio.workspace = true From 5ac483d9ce68b8e5883cb25553aa90d873575472 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 22 Dec 2025 16:21:00 -0500 Subject: [PATCH 11/13] docs: update example for `connect_bluetooth()` and bump version --- nmrs/Cargo.toml | 2 +- nmrs/src/api/network_manager.rs | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index 9ff376a6..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" diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 1e273e1e..cccd1d60 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -174,6 +174,23 @@ impl NetworkManager { /// 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 } @@ -187,7 +204,7 @@ impl NetworkManager { /// /// # Example /// - /// ```no_run + /// ```rust /// use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; /// /// # async fn example() -> nmrs::Result<()> { From 87a43e69200ab1528a9dd2d2a8616cb606a7fc7b Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 22 Dec 2025 16:40:17 -0500 Subject: [PATCH 12/13] chore: update nix flake --- package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.nix b/package.nix index be90b1f6..af771936 100644 --- a/package.nix +++ b/package.nix @@ -19,7 +19,7 @@ rustPlatform.buildRustPackage { src = ./.; - cargoHash = "sha256-hmkUlcw/D38ASqo7GcNK8p6IqfnFIAQWRrjpWymzKFY="; + cargoHash = "sha256-crqDInO2Dem/nJISUSzyjvaAbcG2it3wGxVvcHvymN4="; nativeBuildInputs = [ pkg-config From 13731a917454646a47540b267e556b7aa12c4c4c Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 12 Jan 2026 12:11:48 -0500 Subject: [PATCH 13/13] chore: update imports and dead code from rebase --- nmrs/src/api/builders/mod.rs | 2 +- nmrs/src/monitoring/info.rs | 6 ++++-- nmrs/src/monitoring/wifi.rs | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index d74f42c5..6588c4ef 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -68,8 +68,8 @@ //! These settings can then be passed to NetworkManager's //! `AddConnection` or `AddAndActivateConnection` D-Bus methods. -pub mod connection_builder; pub mod bluetooth; +pub mod connection_builder; pub mod vpn; pub mod wifi; pub mod wifi_builder; diff --git a/nmrs/src/monitoring/info.rs b/nmrs/src/monitoring/info.rs index 09aa54ff..56bfc8f7 100644 --- a/nmrs/src/monitoring/info.rs +++ b/nmrs/src/monitoring/info.rs @@ -7,8 +7,9 @@ use log::debug; use zbus::Connection; use crate::api::models::{ConnectionError, Network, NetworkInfo}; -use crate::monitoring::wifi::current_ssid; -use crate::types::constants::{rate, security_flags}; +use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::try_log; +use crate::types::constants::{device_type, rate, security_flags}; use crate::util::utils::{ bars_from_strength, channel_from_freq, decode_ssid_or_empty, for_each_access_point, mode_to_string, strength_or_zero, @@ -181,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/wifi.rs b/nmrs/src/monitoring/wifi.rs index abf7b838..47225389 100644 --- a/nmrs/src/monitoring/wifi.rs +++ b/nmrs/src/monitoring/wifi.rs @@ -64,7 +64,7 @@ pub(crate) async fn current_ssid(conn: &Connection) -> Option { ); let ssid_bytes = try_log!(ap.ssid().await, "Failed to get SSID bytes"); let ssid = decode_ssid_or_empty(&ssid_bytes); - return Some(ssid); + return Some(ssid.to_string()); } } } @@ -110,7 +110,7 @@ pub(crate) async fn current_connection_info(conn: &Connection) -> Option<(String 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, frequency)); + return Some((ssid.to_string(), frequency)); } } }