diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 37e0ff45..86a8705e 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -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 @@ -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 diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index 19405224..446a4583 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nmrs" -version = "3.1.1" +version = "3.1.2" authors = ["Akrm Al-Hakimi "] edition.workspace = true rust-version = "1.90.0" diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 65bf99f0..ab5a9eb0 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -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 } @@ -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 diff --git a/nmrs/src/core/airplane.rs b/nmrs/src/core/airplane.rs index b903635f..7ba1a7c2 100644 --- a/nmrs/src/core/airplane.rs +++ b/nmrs/src/core/airplane.rs @@ -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}")) })?; @@ -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 { @@ -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));