Skip to content
Merged
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
6 changes: 5 additions & 1 deletion nmrs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to the `nmrs` crate will be documented in this file.

## [Unreleased]

## [3.1.2] - 2026-05-14
- `set_bluetooth_radio_enabled` now toggles kernel rfkill before BlueZ adapter `Powered`, fixing airplane-mode state desync with rfkill-based consumers ([#417](https://github.com/cachebag/nmrs/issues/418))

## [3.1.1] - 2026-05-13
### Fixed
- `set_airplane_mode` now treats `BluetoothToggleFailed` as a non-fatal
Expand Down Expand Up @@ -275,7 +278,8 @@ All notable changes to the `nmrs` crate will be documented in this file.
[3.0.1]: https://github.com/cachebag/nmrs/compare/nmrs-v3.0.0...nmrs-v3.0.1
[3.1.0]: https://github.com/cachebag/nmrs/compare/nmrs-v3.0.1...nmrs-v3.1.0
[3.1.1]: https://github.com/cachebag/nmrs/compare/nmrs-v3.1.0...nmrs-v3.1.1
[Unreleased]: https://github.com/cachebag/nmrs/compare/nmrs-v3.1.1...HEAD
[3.1.2]: https://github.com/cachebag/nmrs/compare/nmrs-v1.2.0...nmrs-v3.1.2
[Unreleased]: https://github.com/cachebag/nmrs/compare/nmrs-v3.1.2...HEAD
[1.1.0]: https://github.com/cachebag/nmrs/compare/nmrs-v1.0.1...nmrs-v1.1.0
[1.0.1]: https://github.com/cachebag/nmrs/compare/nmrs-v1.0.0...nmrs-v1.0.1
[1.0.0]: https://github.com/cachebag/nmrs/compare/v0.5.0-beta...nmrs-v1.0.0
Expand Down
2 changes: 1 addition & 1 deletion nmrs/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "nmrs"
version = "3.1.1"
version = "3.1.2"
authors = ["Akrm Al-Hakimi <alhakimiakrmj@gmail.com>"]
edition.workspace = true
rust-version = "1.90.0"
Expand Down
8 changes: 8 additions & 0 deletions nmrs/src/api/network_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,9 @@ impl NetworkManager {
/// Returns [`BluezUnavailable`](crate::ConnectionError::BluezUnavailable) if BlueZ is not running
/// or no adapters exist, or [`BluetoothToggleFailed`](crate::ConnectionError::BluetoothToggleFailed)
/// if any adapter could not be toggled or did not reach the requested power state.
///
/// Uses kernel rfkill (`rfkill block/unblock bluetooth`) as the primary
/// mechanism, then also toggles BlueZ adapter `Powered` properties.
pub async fn set_bluetooth_radio_enabled(&self, enabled: bool) -> Result<()> {
airplane::set_bluetooth_radio_enabled(&self.conn, enabled).await
}
Expand All @@ -782,6 +785,11 @@ impl NetworkManager {
///
/// **`enabled = true` means airplane mode is on, i.e. radios are off.**
///
/// Wi-Fi and WWAN are toggled via NetworkManager properties. Bluetooth
/// is toggled via kernel rfkill plus BlueZ adapter `Powered`, ensuring
/// the soft-block state is visible to other components that read rfkill
/// to determine airplane-mode status.
///
/// Does not fail fast: attempts all three toggles concurrently and
/// returns the first error at the end, if any. A missing Bluetooth
/// stack (BlueZ not running or no adapters) is treated as a successful
Expand Down
40 changes: 28 additions & 12 deletions nmrs/src/core/airplane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,24 +154,42 @@ pub(crate) async fn set_wwan_enabled(conn: &Connection, enabled: bool) -> Result
Ok(nm.set_wwan_enabled(enabled).await?)
}

/// Enables or disables Bluetooth radio by toggling all BlueZ adapters.
/// Enables or disables Bluetooth radio via kernel rfkill and BlueZ adapters.
///
/// After writing `Powered` we wait up to [`BLUEZ_POWER_SETTLE_TIMEOUT`] for
/// all adapters' reported state to actually flip. Otherwise a consumer that
/// re-reads [`bluetooth_radio_state`] right after this call can observe the
/// pre-toggle value briefly and conclude the toggle didn't take effect.
/// Uses `rfkill block bluetooth` / `rfkill unblock bluetooth` as the primary
/// mechanism — this is authoritative, persistent, and matches what other
/// Cosmic components (e.g. `cosmic-settings-airplane-mode-subscription`) read
/// back when determining airplane-mode state.
///
/// Adapters are toggled concurrently with a single overall timeout to keep
/// latency bounded regardless of adapter count.
/// After rfkill, we also toggle BlueZ adapter `Powered` properties and wait
/// up to [`BLUEZ_POWER_SETTLE_TIMEOUT`] for them to settle, so that a
/// read-after-write of [`bluetooth_radio_state`] sees the correct value.
///
/// # Errors
///
/// - [`ConnectionError::BluezUnavailable`] if BlueZ is not running or no
/// adapters exist.
/// - [`ConnectionError::BluetoothToggleFailed`] if one or more adapters could
/// not be toggled, or their `Powered` property did not reach the requested
/// state (e.g., D-Bus errors or timeout).
/// - [`ConnectionError::BluetoothToggleFailed`] if rfkill failed, or one or
/// more BlueZ adapters could not be toggled / did not reach the requested
/// state.
pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool) -> Result<()> {
let rfkill_arg = if enabled { "unblock" } else { "block" };
let rfkill_status = tokio::process::Command::new("rfkill")
.arg(rfkill_arg)
.arg("bluetooth")
.status()
.await;

match rfkill_status {
Ok(status) if status.success() => {}
Ok(status) => {
warn!("rfkill {rfkill_arg} bluetooth exited with {status}");
}
Err(e) => {
warn!("failed to run rfkill {rfkill_arg} bluetooth: {e}");
}
}

let adapter_paths = enumerate_bluetooth_adapters(conn).await.map_err(|e| {
ConnectionError::BluezUnavailable(format!("failed to enumerate adapters: {e}"))
})?;
Expand All @@ -184,7 +202,6 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool

let n_adapters = adapter_paths.len();

// Build proxies and toggle power concurrently
let toggle_futures = adapter_paths.iter().map(|path| async move {
let proxy = match BluezAdapterProxy::builder(conn).path(path.as_str()) {
Ok(builder) => match builder.build().await {
Expand Down Expand Up @@ -220,7 +237,6 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool

let successful_proxies: Vec<_> = results.into_iter().flatten().collect();

// Wait for all adapters' Powered to settle, with a single overall timeout
let wait_futures = successful_proxies
.iter()
.map(|proxy| wait_for_powered_no_timeout(proxy, enabled));
Expand Down
Loading