Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions nmrs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
61 changes: 45 additions & 16 deletions nmrs/src/api/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

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
///
Expand All @@ -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<Self, ConnectionError> {
validate_bluetooth_address(&bdaddr)?;
Ok(Self {
bdaddr,
bt_device_type,
adapter: Some(adapter),
})
}
}
Expand All @@ -1776,8 +1805,6 @@ impl BluetoothIdentity {
///
/// # Example
///
/// # Example
///
/// ```rust
/// use nmrs::models::{BluetoothDevice, BluetoothNetworkRole, DeviceState};
///
Expand All @@ -1788,6 +1815,7 @@ impl BluetoothIdentity {
/// Some("Phone".into()),
/// role,
/// DeviceState::Activated,
/// Some("hci0".into()),
/// );
/// ```
#[non_exhaustive]
Expand All @@ -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<String>,
}

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
Expand All @@ -1828,6 +1850,7 @@ impl BluetoothDevice {
/// Some("Phone".into()),
/// role,
/// DeviceState::Activated,
/// Some("hci0".into()),
/// );
/// ```
#[must_use]
Expand All @@ -1837,13 +1860,15 @@ impl BluetoothDevice {
alias: Option<String>,
bt_caps: u32,
state: DeviceState,
adapter: Option<String>,
) -> Self {
Self {
bdaddr,
name,
alias,
bt_caps,
state,
adapter,
}
}
}
Expand Down Expand Up @@ -2972,13 +2997,15 @@ mod tests {
Some("Phone".into()),
role,
DeviceState::Activated,
Some("hci0".into()),
);

assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13");
assert_eq!(device.name, Some("MyPhone".into()));
assert_eq!(device.alias, Some("Phone".into()));
assert!(matches!(device.bt_caps, _role));
assert_eq!(device.state, DeviceState::Activated);
assert_eq!(device.adapter, Some("hci0".into()));
}

#[test]
Expand All @@ -2990,6 +3017,7 @@ mod tests {
Some("Phone".into()),
role,
DeviceState::Activated,
None,
);

let display_str = format!("{}", device);
Expand All @@ -3007,6 +3035,7 @@ mod tests {
None,
role,
DeviceState::Disconnected,
None,
);

let display_str = format!("{}", device);
Expand Down
38 changes: 32 additions & 6 deletions nmrs/src/core/bluetooth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ use crate::{
pub(crate) async fn populate_bluez_info(
conn: &Connection,
bdaddr: &str,
adapter: Option<&str>,
) -> Result<(Option<String>, Option<String>)> {
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)?
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 = [
Expand All @@ -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}"
);
Expand All @@ -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.
}
4 changes: 3 additions & 1 deletion nmrs/src/core/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,15 @@ pub(crate) async fn list_bluetooth_devices(conn: &Connection) -> Result<Vec<Blue
let raw_state = d_proxy.state().await?;
let state = raw_state.into();

let bluez_info = populate_bluez_info(conn, &bdaddr).await?;
let bluez_info = populate_bluez_info(conn, &bdaddr, None).await?;

devices.push(BluetoothDevice::new(
bdaddr,
bluez_info.0,
bluez_info.1,
bt_caps,
state,
None,
));
}
Ok(devices)
Expand Down Expand Up @@ -269,6 +270,7 @@ mod tests {
Some("Test".into()),
panu,
DeviceState::Activated,
None,
);

assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13");
Expand Down
20 changes: 15 additions & 5 deletions nmrs/src/util/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,21 @@ pub(crate) async fn extract_connection_state_reason(

/// Constructs a BlueZ D-Bus object path from a Bluetooth device address.
///
/// Converts a BDADDR like `"00:1A:7D:DA:71:13"` into
/// `"/org/bluez/hci0/dev_00_1A_7D_DA_71_13"`.
// TODO: Instead of hardcoding hci0, determine the actual adapter name.
pub(crate) fn bluez_device_path(bdaddr: &str) -> 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.
Expand Down
2 changes: 2 additions & 0 deletions nmrs/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -1150,6 +1151,7 @@ fn test_bluetooth_device_display() {
Some("Phone".into()),
role,
DeviceState::Activated,
None,
);

let display = format!("{}", device);
Expand Down
Loading