diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 9d9a0927..47160f57 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -3,6 +3,9 @@ All notable changes to the `nmrs` crate will be documented in this file. ## [Unreleased] +### Added +- Support for specifying Bluetooth adapter in `BluetoothIdentity` ([#267](https://github.com/cachebag/nmrs/pull/267)) + ### Changed - Convert BDADDR to BlueZ device path via `bluez_device_path` helper ([#266](https://github.com/cachebag/nmrs/pull/266)) diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 87de7dab..a6f78183 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -1729,15 +1729,12 @@ pub struct BluetoothIdentity { pub bdaddr: String, /// Bluetooth device type (DUN or PANU) pub bt_device_type: BluetoothNetworkRole, + /// BlueZ adapter name (e.g. `"hci0"`, `"hci1"`). Defaults to `"hci0"` when `None`. + pub adapter: Option, } impl BluetoothIdentity { - /// Creates a new `BluetoothIdentity`. - /// - /// # Arguments - /// - /// * `bdaddr` - Bluetooth MAC address (e.g., "00:1A:7D:DA:71:13") - /// * `bt_device_type` - Bluetooth network role (PanU or Dun) + /// Creates a new `BluetoothIdentity` using the default adapter. /// /// # Errors /// @@ -1762,6 +1759,38 @@ impl BluetoothIdentity { Ok(Self { bdaddr, bt_device_type, + adapter: None, + }) + } + + /// Creates a new `BluetoothIdentity` targeting a specific adapter. + /// + /// # Errors + /// + /// Returns a `ConnectionError` if the provided `bdaddr` is not a + /// valid Bluetooth MAC address format. + /// + /// # Example + /// + /// ```rust + /// use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; + /// + /// let identity = BluetoothIdentity::with_adapter( + /// "00:1A:7D:DA:71:13".into(), + /// BluetoothNetworkRole::PanU, + /// "hci1".into(), + /// ).unwrap(); + /// ``` + pub fn with_adapter( + bdaddr: String, + bt_device_type: BluetoothNetworkRole, + adapter: String, + ) -> Result { + validate_bluetooth_address(&bdaddr)?; + Ok(Self { + bdaddr, + bt_device_type, + adapter: Some(adapter), }) } } @@ -1776,8 +1805,6 @@ impl BluetoothIdentity { /// /// # Example /// -/// # Example -/// /// ```rust /// use nmrs::models::{BluetoothDevice, BluetoothNetworkRole, DeviceState}; /// @@ -1788,6 +1815,7 @@ impl BluetoothIdentity { /// Some("Phone".into()), /// role, /// DeviceState::Activated, +/// Some("hci0".into()), /// ); /// ``` #[non_exhaustive] @@ -1803,19 +1831,13 @@ pub struct BluetoothDevice { pub bt_caps: u32, /// Current device state pub state: DeviceState, + /// BlueZ adapter name (e.g. `"hci0"`) + pub adapter: Option, } impl BluetoothDevice { /// Creates a new `BluetoothDevice`. /// - /// # Arguments - /// - /// * `bdaddr` - Bluetooth MAC address - /// * `name` - Friendly device name from BlueZ - /// * `alias` - Device alias from BlueZ - /// * `bt_caps` - Bluetooth device capabilities/type - /// * `state` - Current device state - /// /// # Example /// /// ```rust @@ -1828,6 +1850,7 @@ impl BluetoothDevice { /// Some("Phone".into()), /// role, /// DeviceState::Activated, + /// Some("hci0".into()), /// ); /// ``` #[must_use] @@ -1837,6 +1860,7 @@ impl BluetoothDevice { alias: Option, bt_caps: u32, state: DeviceState, + adapter: Option, ) -> Self { Self { bdaddr, @@ -1844,6 +1868,7 @@ impl BluetoothDevice { alias, bt_caps, state, + adapter, } } } @@ -2972,6 +2997,7 @@ mod tests { Some("Phone".into()), role, DeviceState::Activated, + Some("hci0".into()), ); assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); @@ -2979,6 +3005,7 @@ mod tests { assert_eq!(device.alias, Some("Phone".into())); assert!(matches!(device.bt_caps, _role)); assert_eq!(device.state, DeviceState::Activated); + assert_eq!(device.adapter, Some("hci0".into())); } #[test] @@ -2990,6 +3017,7 @@ mod tests { Some("Phone".into()), role, DeviceState::Activated, + None, ); let display_str = format!("{}", device); @@ -3007,6 +3035,7 @@ mod tests { None, role, DeviceState::Disconnected, + None, ); let display_str = format!("{}", device); diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 676dc393..d41a80ed 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -43,10 +43,11 @@ use crate::{ pub(crate) async fn populate_bluez_info( conn: &Connection, bdaddr: &str, + adapter: Option<&str>, ) -> Result<(Option, Option)> { validate_bluetooth_address(bdaddr)?; - let bluez_path = bluez_device_path(bdaddr); + let bluez_path = bluez_device_path(bdaddr, adapter); match BluezDeviceExtProxy::builder(conn) .path(bluez_path)? @@ -142,8 +143,11 @@ pub(crate) async fn connect_bluetooth( // Check for saved connection let saved = get_saved_connection_path(conn, name).await?; - let specific_object = OwnedObjectPath::try_from(bluez_device_path(&settings.bdaddr)) - .map_err(|e| ConnectionError::InvalidAddress(format!("Invalid BlueZ path: {e}")))?; + let specific_object = OwnedObjectPath::try_from(bluez_device_path( + &settings.bdaddr, + settings.adapter.as_deref(), + )) + .map_err(|e| ConnectionError::InvalidAddress(format!("Invalid BlueZ path: {e}")))?; match saved { Some(saved_path) => { @@ -238,13 +242,21 @@ mod tests { use crate::models::BluetoothNetworkRole; #[test] - fn test_bluez_path_format() { + fn test_bluez_path_format_default_adapter() { assert_eq!( - bluez_device_path("00:1A:7D:DA:71:13"), + bluez_device_path("00:1A:7D:DA:71:13", None), "/org/bluez/hci0/dev_00_1A_7D_DA_71_13" ); } + #[test] + fn test_bluez_path_format_specific_adapter() { + assert_eq!( + bluez_device_path("00:1A:7D:DA:71:13", Some("hci1")), + "/org/bluez/hci1/dev_00_1A_7D_DA_71_13" + ); + } + #[test] fn test_bluez_path_format_various_addresses() { let test_cases = [ @@ -255,7 +267,7 @@ mod tests { for (bdaddr, expected) in test_cases { assert_eq!( - bluez_device_path(bdaddr), + bluez_device_path(bdaddr, None), expected, "Failed for bdaddr: {bdaddr}" ); @@ -268,12 +280,26 @@ mod tests { BluetoothIdentity::new("00:1A:7D:DA:71:13".into(), BluetoothNetworkRole::PanU).unwrap(); assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); + assert_eq!(identity.adapter, None); assert!(matches!( identity.bt_device_type, BluetoothNetworkRole::PanU )); } + #[test] + fn test_bluetooth_identity_with_adapter() { + let identity = BluetoothIdentity::with_adapter( + "00:1A:7D:DA:71:13".into(), + BluetoothNetworkRole::PanU, + "hci1".into(), + ) + .unwrap(); + + assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); + assert_eq!(identity.adapter, Some("hci1".into())); + } + // 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 58a9a1ca..963bed48 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -177,7 +177,7 @@ pub(crate) async fn list_bluetooth_devices(conn: &Connection) -> Result Result String { - format!("/org/bluez/hci0/dev_{}", bdaddr.replace(':', "_")) +/// Uses the given adapter name (e.g. `"hci0"`) or defaults to `"hci0"` +/// when `None` is provided. +/// +/// # Example +/// +/// ```ignore +/// bluez_device_path("00:1A:7D:DA:71:13", None) +/// // => "/org/bluez/hci0/dev_00_1A_7D_DA_71_13" +/// +/// bluez_device_path("00:1A:7D:DA:71:13", Some("hci1")) +/// // => "/org/bluez/hci1/dev_00_1A_7D_DA_71_13" +/// ``` +pub(crate) fn bluez_device_path(bdaddr: &str, adapter: Option<&str>) -> String { + let adapter = adapter.unwrap_or("hci0"); + format!("/org/bluez/{adapter}/dev_{}", bdaddr.replace(':', "_")) } /// Macro to convert Result to Option with error logging. diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 1e67efb3..346d2e1e 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -1130,6 +1130,7 @@ fn test_bluetooth_device_structure() { Some("Phone".into()), role, DeviceState::Activated, + None, ); assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); @@ -1150,6 +1151,7 @@ fn test_bluetooth_device_display() { Some("Phone".into()), role, DeviceState::Activated, + None, ); let display = format!("{}", device);