From 157ef61eacde089087faa1f01619c13324ccc2ac Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Thu, 7 May 2026 11:50:08 -0400 Subject: [PATCH 1/9] fix(airplane): treat absent radios as not-present and wait for bluez Powered to settle so toggling airplane mode no longer leaves wifi soft-killed on hosts without bluetooth --- nmrs/CHANGELOG.md | 19 +++++ nmrs/src/api/models/radio.rs | 116 +++++++++++++++++++++++++++--- nmrs/src/api/network_manager.rs | 22 ++++-- nmrs/src/core/airplane.rs | 123 +++++++++++++++++++++++++++++--- nmrs/src/types/constants.rs | 2 + 5 files changed, 259 insertions(+), 23 deletions(-) diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 9a9797ad..e8f2dcb0 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -6,6 +6,25 @@ All notable changes to the `nmrs` crate will be documented in this file. - Implement loopback support ([#391](https://github.com/cachebag/nmrs/issues/391)) - Implement add VLAN (802.1Q) device support with VlanConfig model and connection builder([#392](https://github.com/cachebag/nmrs/issues/392)) +### Added +- `RadioState::present` indicates whether a controllable instance of the radio + exists on the host. `RadioState::with_presence(enabled, hardware_enabled, + present)` constructor; `RadioState::new` keeps existing behavior and defaults + `present = true`. + +### Fixed +- `AirplaneModeState::is_airplane_mode` no longer returns `false` on hosts + without Bluetooth/WWAN. Radios reported with `present = false` are now + ignored when computing both `is_airplane_mode` and `any_hardware_killed`. +- `set_airplane_mode` no longer returns `BluezUnavailable` (and therefore no + longer leaves Wi-Fi soft-killed while reporting failure to the caller) when + the host has no Bluetooth stack. A missing BlueZ is treated as a successful + no-op for the Bluetooth leg of the toggle. +- `set_bluetooth_radio_enabled` waits up to 2s for each adapter's `Powered` + property to actually flip before returning, so a read-after-write of + `airplane_mode_state()` no longer observes the pre-toggle Bluetooth state + and concludes that airplane mode failed to engage. + ## [3.0.1] - 2026-04-25 ### Changed - Lower MSRV from 1.94.0 to 1.90.0 diff --git a/nmrs/src/api/models/radio.rs b/nmrs/src/api/models/radio.rs index 99053cad..6c6880a3 100644 --- a/nmrs/src/api/models/radio.rs +++ b/nmrs/src/api/models/radio.rs @@ -4,6 +4,11 @@ //! and a hardware-enabled flag (reflecting the kernel rfkill state) for each //! radio. [`RadioState`] captures both, and [`AirplaneModeState`] aggregates //! Wi-Fi, WWAN, and Bluetooth into a single snapshot. +//! +//! On hosts without a given radio (e.g. a desktop with no Bluetooth adapter +//! or no WWAN modem), the corresponding [`RadioState`] is reported with +//! `present = false` so callers can ignore it when deciding whether the +//! system is in airplane mode. /// Software and hardware enabled state for a single radio. /// @@ -11,6 +16,9 @@ /// `hardware_enabled` reflects the kernel rfkill state and cannot be changed /// from userspace — if `false`, setting `enabled = true` is accepted by NM /// but the radio remains off until hardware is unkilled. +/// `present` reflects whether this kind of radio actually exists on the +/// host. If `false`, the `enabled` and `hardware_enabled` values are best-effort +/// defaults and should not factor into airplane-mode decisions. #[non_exhaustive] #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub struct RadioState { @@ -19,15 +27,31 @@ pub struct RadioState { /// Hardware-enabled: is rfkill allowing this radio? /// If `false`, `enabled = true` is a no-op until hardware is unkilled. pub hardware_enabled: bool, + /// Whether a controllable instance of this radio exists on the host. + /// + /// `false` means the system has no Wi-Fi card / no modem / no Bluetooth + /// adapter (or BlueZ is not running). Consumers should treat such radios + /// as not contributing to airplane-mode state. + pub present: bool, } impl RadioState { - /// Creates a new `RadioState`. + /// Creates a new `RadioState` for a radio that is present on the host. + /// + /// Equivalent to `RadioState::with_presence(enabled, hardware_enabled, true)`. #[must_use] pub fn new(enabled: bool, hardware_enabled: bool) -> Self { + Self::with_presence(enabled, hardware_enabled, true) + } + + /// Creates a new `RadioState`, explicitly recording whether the radio + /// exists on the host. + #[must_use] + pub fn with_presence(enabled: bool, hardware_enabled: bool, present: bool) -> Self { Self { enabled, hardware_enabled, + present, } } } @@ -35,6 +59,16 @@ impl RadioState { /// Aggregated radio state for all radios that `nmrs` can control. /// /// Returned by [`NetworkManager::airplane_mode_state`](crate::NetworkManager::airplane_mode_state). +/// +/// Radios with `present = false` (e.g. Bluetooth on a host without BlueZ, +/// WWAN on a host without a modem) are ignored by [`is_airplane_mode`] and +/// [`any_hardware_killed`]. This means a wifi-only laptop counts as being +/// in airplane mode the moment its Wi-Fi software switch is off, instead +/// of erroneously requiring an absent Bluetooth or WWAN radio to also be +/// off. +/// +/// [`is_airplane_mode`]: AirplaneModeState::is_airplane_mode +/// [`any_hardware_killed`]: AirplaneModeState::any_hardware_killed #[non_exhaustive] #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub struct AirplaneModeState { @@ -57,20 +91,32 @@ impl AirplaneModeState { } } - /// Returns `true` if every radio `nmrs` can control is software-disabled. + /// Returns `true` if every *present* radio is software-disabled. /// - /// This is the "airplane mode is on" state — all radios off. + /// Radios with `present = false` are ignored. If no controllable radios + /// exist on the host at all, this returns `false` — there is no + /// meaningful "airplane mode" to be in. #[must_use] pub fn is_airplane_mode(&self) -> bool { - !self.wifi.enabled && !self.wwan.enabled && !self.bluetooth.enabled + let mut any_present = false; + for radio in [&self.wifi, &self.wwan, &self.bluetooth] { + if !radio.present { + continue; + } + any_present = true; + if radio.enabled { + return false; + } + } + any_present } - /// Returns `true` if any radio has its hardware kill switch active. + /// Returns `true` if any *present* radio has its hardware kill switch active. #[must_use] pub fn any_hardware_killed(&self) -> bool { - !self.wifi.hardware_enabled - || !self.wwan.hardware_enabled - || !self.bluetooth.hardware_enabled + [&self.wifi, &self.wwan, &self.bluetooth] + .into_iter() + .any(|r| r.present && !r.hardware_enabled) } } @@ -124,5 +170,59 @@ mod tests { let rs = RadioState::new(true, false); assert!(rs.enabled); assert!(!rs.hardware_enabled); + assert!(rs.present, "RadioState::new defaults to present = true"); + } + + #[test] + fn radio_state_with_presence() { + let rs = RadioState::with_presence(true, true, false); + assert!(rs.enabled); + assert!(rs.hardware_enabled); + assert!(!rs.present); + } + + #[test] + fn absent_radios_do_not_block_airplane_mode() { + // Wi-Fi-only host: Bluetooth and WWAN are absent. Disabling Wi-Fi + // alone should put us in airplane mode. + let state = AirplaneModeState::new( + RadioState::new(false, true), + RadioState::with_presence(true, true, false), + RadioState::with_presence(true, false, false), + ); + assert!(state.is_airplane_mode()); + } + + #[test] + fn absent_radios_do_not_count_as_hardware_killed() { + let state = AirplaneModeState::new( + RadioState::new(true, true), + RadioState::with_presence(true, false, false), + RadioState::with_presence(true, false, false), + ); + assert!(!state.any_hardware_killed()); + } + + #[test] + fn no_radios_present_is_not_airplane_mode() { + let state = AirplaneModeState::new( + RadioState::with_presence(true, true, false), + RadioState::with_presence(true, true, false), + RadioState::with_presence(true, true, false), + ); + assert!( + !state.is_airplane_mode(), + "with no controllable radios there is no airplane mode" + ); + } + + #[test] + fn one_radio_present_and_off_is_airplane_mode() { + let state = AirplaneModeState::new( + RadioState::new(false, true), + RadioState::with_presence(true, true, false), + RadioState::with_presence(true, true, false), + ); + assert!(state.is_airplane_mode()); } } diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 16a191d9..ce3c6ee2 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -721,8 +721,9 @@ impl NetworkManager { /// Returns the combined software/hardware state of the Bluetooth radio. /// /// Reads power state from all BlueZ adapters and cross-references rfkill. - /// If BlueZ is not running or no adapters exist, returns - /// `RadioState { enabled: true, hardware_enabled: false }`. + /// If BlueZ is not running or no adapters exist, returns a [`RadioState`] + /// with `present = false` so callers can ignore Bluetooth on hosts that + /// don't have it. pub async fn bluetooth_radio_state(&self) -> Result { airplane::bluetooth_radio_state(&self.conn).await } @@ -730,7 +731,11 @@ impl NetworkManager { /// Returns the aggregated airplane-mode state across all radios. /// /// Fans out to Wi-Fi, WWAN, and Bluetooth concurrently and returns - /// an [`AirplaneModeState`] snapshot. + /// an [`AirplaneModeState`] snapshot. Radios that are not actually + /// present on the host (no Wi-Fi card, no modem, no BlueZ) are reported + /// with `present = false` and are ignored by + /// [`AirplaneModeState::is_airplane_mode`] / + /// [`AirplaneModeState::any_hardware_killed`]. pub async fn airplane_mode_state(&self) -> Result { airplane::airplane_mode_state(&self.conn).await } @@ -740,6 +745,11 @@ impl NetworkManager { /// This replaces the deprecated [`set_wifi_enabled`](Self::set_wifi_enabled). /// If the radio is hardware-killed, NM accepts the write but the radio /// remains off until hardware is unkilled. + /// + /// Note: NetworkManager implements this by writing rfkill soft blocks, + /// which most distributions persist across reboots via `rfkill-restore` + /// or systemd. A wifi disabled this way will remain disabled until it + /// is explicitly re-enabled. pub async fn set_wireless_enabled(&self, enabled: bool) -> Result<()> { airplane::set_wireless_enabled(&self.conn, enabled).await } @@ -764,7 +774,11 @@ impl NetworkManager { /// **`enabled = true` means airplane mode is on, i.e. radios are off.** /// /// Does not fail fast: attempts all three toggles concurrently and - /// returns the first error at the end, if any. + /// returns the first error at the end, if any. A missing Bluetooth + /// stack (BlueZ not running or no adapters) is treated as a successful + /// no-op rather than as an error so that flipping airplane mode on a + /// wifi-only host still succeeds and leaves the toggle in the expected + /// state. pub async fn set_airplane_mode(&self, enabled: bool) -> Result<()> { airplane::set_airplane_mode(&self.conn, enabled).await } diff --git a/nmrs/src/core/airplane.rs b/nmrs/src/core/airplane.rs index f27df3d3..50e881ed 100644 --- a/nmrs/src/core/airplane.rs +++ b/nmrs/src/core/airplane.rs @@ -2,15 +2,30 @@ //! //! Combines radio state from NetworkManager (Wi-Fi, WWAN), BlueZ (Bluetooth //! adapter power), and kernel rfkill into a single [`AirplaneModeState`]. +//! +//! Each radio's state carries a `present` flag so consumers can ignore radios +//! the host does not actually have (no Wi-Fi card, no modem, BlueZ not +//! running) instead of blocking airplane-mode aggregation forever. + +use std::time::Duration; +use futures::{FutureExt, StreamExt, future}; +use futures_timer::Delay; use log::warn; +use std::pin::pin; use zbus::Connection; use crate::api::models::{AirplaneModeState, RadioState}; use crate::core::rfkill::read_rfkill; -use crate::dbus::{BluezAdapterProxy, NMProxy}; +use crate::dbus::{BluezAdapterProxy, NMDeviceProxy, NMProxy}; +use crate::types::constants::device_type; use crate::{ConnectionError, Result}; +/// Maximum time to wait for a BlueZ adapter's `Powered` property to reflect +/// a write before we give up and return Ok anyway. BlueZ usually settles in +/// well under a second; we cap at two to avoid hanging UI consumers. +const BLUEZ_POWER_SETTLE_TIMEOUT: Duration = Duration::from_secs(2); + /// Reads Wi-Fi radio state from NetworkManager, cross-referenced with rfkill. pub(crate) async fn wifi_state(conn: &Connection) -> Result { let nm = NMProxy::new(conn).await?; @@ -19,8 +34,13 @@ pub(crate) async fn wifi_state(conn: &Connection) -> Result { let rfkill = read_rfkill(); let hardware_enabled = reconcile_hardware(nm_hw, rfkill.wlan_hard_block, "wifi"); + let present = has_device_of_type(conn, device_type::WIFI).await; - Ok(RadioState::new(enabled, hardware_enabled)) + Ok(RadioState::with_presence( + enabled, + hardware_enabled, + present, + )) } /// Reads WWAN radio state from NetworkManager, cross-referenced with rfkill. @@ -31,20 +51,25 @@ pub(crate) async fn wwan_state(conn: &Connection) -> Result { let rfkill = read_rfkill(); let hardware_enabled = reconcile_hardware(nm_hw, rfkill.wwan_hard_block, "wwan"); + let present = has_device_of_type(conn, device_type::MODEM).await; - Ok(RadioState::new(enabled, hardware_enabled)) + Ok(RadioState::with_presence( + enabled, + hardware_enabled, + present, + )) } /// Reads Bluetooth radio state from BlueZ adapters, cross-referenced with rfkill. /// -/// If BlueZ is not running or no adapters exist, returns -/// `RadioState { enabled: true, hardware_enabled: false }` — "hardware killed" -/// is the honest answer when there is no Bluetooth stack. +/// If BlueZ is not running or no adapters exist, returns a `RadioState` +/// with `present = false` so callers can ignore Bluetooth entirely on +/// hosts that don't have it. pub(crate) async fn bluetooth_radio_state(conn: &Connection) -> Result { let adapter_paths = match enumerate_bluetooth_adapters(conn).await { Ok(paths) if !paths.is_empty() => paths, Ok(_) | Err(_) => { - return Ok(RadioState::new(true, false)); + return Ok(RadioState::with_presence(false, false, false)); } }; @@ -70,7 +95,11 @@ pub(crate) async fn bluetooth_radio_state(conn: &Connection) -> Result Result /// Enables or disables Bluetooth radio by toggling all BlueZ adapters. /// -/// If BlueZ is not running, returns `BluezUnavailable`. +/// After writing `Powered` we wait up to [`BLUEZ_POWER_SETTLE_TIMEOUT`] for +/// the adapter's 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. +/// +/// If BlueZ is not running, returns [`ConnectionError::BluezUnavailable`]. pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool) -> Result<()> { let adapter_paths = enumerate_bluetooth_adapters(conn).await.map_err(|e| { ConnectionError::BluezUnavailable(format!("failed to enumerate adapters: {e}")) @@ -119,6 +153,7 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool .build() .await?; proxy.set_powered(enabled).await?; + wait_for_powered(&proxy, enabled, BLUEZ_POWER_SETTLE_TIMEOUT).await; Ok(()) } .await; @@ -140,7 +175,9 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool /// Flips all three radios in parallel. /// /// `enabled = true` means airplane mode **on** (radios **off**). -/// Does not fail fast — attempts all three and returns the first error. +/// Does not fail fast — attempts all three and returns the first error, +/// except that a missing Bluetooth stack is not treated as a failure +/// (`BluezUnavailable` is silently downgraded to a no-op). pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Result<()> { let radio_on = !enabled; @@ -154,7 +191,15 @@ pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Resul // Return the first error, but don't short-circuit — all three have been attempted. wifi_res?; wwan_res?; - bt_res?; + match bt_res { + Ok(()) => {} + Err(ConnectionError::BluezUnavailable(_)) => { + // No Bluetooth on this host — that's fine, don't fail the whole + // call (and don't leave callers thinking the wifi/wwan flips + // didn't happen). + } + Err(e) => return Err(e), + } Ok(()) } @@ -194,3 +239,59 @@ fn reconcile_hardware(nm_hardware_enabled: bool, rfkill_hard_block: bool, radio: } nm_hardware_enabled && !rfkill_hard_block } + +/// Returns `true` if NetworkManager has at least one device of the given type. +/// +/// Failures (D-Bus error, no NM running) are treated as "no devices of this +/// type" — we'd rather mark a radio as absent than spuriously block the +/// airplane-mode aggregator. +async fn has_device_of_type(conn: &Connection, type_code: u32) -> bool { + let Ok(nm) = NMProxy::new(conn).await else { + return false; + }; + let Ok(paths) = nm.get_devices().await else { + return false; + }; + for p in paths { + let Ok(builder) = NMDeviceProxy::builder(conn).path(p) else { + continue; + }; + let Ok(dev) = builder.build().await else { + continue; + }; + if let Ok(t) = dev.device_type().await + && t == type_code + { + return true; + } + } + false +} + +/// Waits for a BlueZ adapter's `Powered` property to settle on `target`. +/// +/// Subscribes to `PropertiesChanged` on `Powered` first, then re-reads the +/// current value (so we don't miss a fast transition that happened between +/// the `set_powered` write and the subscription). Returns when the property +/// matches `target` or when `timeout` elapses (whichever comes first). +async fn wait_for_powered(proxy: &BluezAdapterProxy<'_>, target: bool, timeout: Duration) { + let mut stream = proxy.receive_powered_changed().await; + + if proxy.powered().await.unwrap_or(target) == target { + return; + } + + let watcher = async { + while let Some(change) = stream.next().await { + if let Ok(value) = change.get().await + && value == target + { + return; + } + } + }; + + let watcher = pin!(watcher.fuse()); + let timer = pin!(Delay::new(timeout).fuse()); + let _ = future::select(watcher, timer).await; +} diff --git a/nmrs/src/types/constants.rs b/nmrs/src/types/constants.rs index 630454ee..f7246074 100644 --- a/nmrs/src/types/constants.rs +++ b/nmrs/src/types/constants.rs @@ -8,6 +8,8 @@ pub mod device_type { pub const ETHERNET: u32 = 1; pub const WIFI: u32 = 2; pub const BLUETOOTH: u32 = 5; + /// Mobile broadband / WWAN modem device. + pub const MODEM: u32 = 8; // pub const WIFI_P2P: u32 = 30; // pub const LOOPBACK: u32 = 32; } From d5639d2be64458055d801dd90acc5f2fb3bd0a4b Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 8 May 2026 15:55:31 -0400 Subject: [PATCH 2/9] fix(airplane): batch device enumeration, toggle adapters concurrently, and log swallowed BlueZ errors --- nmrs/src/api/network_manager.rs | 4 +- nmrs/src/core/airplane.rs | 171 +++++++++++++++++++++----------- 2 files changed, 115 insertions(+), 60 deletions(-) diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index ce3c6ee2..94f30c35 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -710,12 +710,12 @@ impl NetworkManager { /// See [`RadioState`] for the distinction between `enabled` (software) /// and `hardware_enabled` (rfkill). pub async fn wifi_state(&self) -> Result { - airplane::wifi_state(&self.conn).await + airplane::wifi_state(&self.conn, None).await } /// Returns the combined software/hardware state of the WWAN radio. pub async fn wwan_state(&self) -> Result { - airplane::wwan_state(&self.conn).await + airplane::wwan_state(&self.conn, None).await } /// Returns the combined software/hardware state of the Bluetooth radio. diff --git a/nmrs/src/core/airplane.rs b/nmrs/src/core/airplane.rs index 50e881ed..7001b10a 100644 --- a/nmrs/src/core/airplane.rs +++ b/nmrs/src/core/airplane.rs @@ -7,6 +7,7 @@ //! the host does not actually have (no Wi-Fi card, no modem, BlueZ not //! running) instead of blocking airplane-mode aggregation forever. +use std::collections::HashSet; use std::time::Duration; use futures::{FutureExt, StreamExt, future}; @@ -21,20 +22,30 @@ use crate::dbus::{BluezAdapterProxy, NMDeviceProxy, NMProxy}; use crate::types::constants::device_type; use crate::{ConnectionError, Result}; -/// Maximum time to wait for a BlueZ adapter's `Powered` property to reflect -/// a write before we give up and return Ok anyway. BlueZ usually settles in -/// well under a second; we cap at two to avoid hanging UI consumers. +/// Maximum time to wait for all BlueZ adapters' `Powered` properties to settle +/// after a write. BlueZ usually settles in well under a second; we cap at two +/// to avoid hanging UI consumers. This is an overall timeout for all adapters, +/// not per-adapter. const BLUEZ_POWER_SETTLE_TIMEOUT: Duration = Duration::from_secs(2); /// Reads Wi-Fi radio state from NetworkManager, cross-referenced with rfkill. -pub(crate) async fn wifi_state(conn: &Connection) -> Result { +/// +/// If `present_device_types` is provided, uses it to determine whether a Wi-Fi +/// device exists; otherwise queries NetworkManager directly. +pub(crate) async fn wifi_state( + conn: &Connection, + present_device_types: Option<&HashSet>, +) -> Result { let nm = NMProxy::new(conn).await?; let enabled = nm.wireless_enabled().await?; let nm_hw = nm.wireless_hardware_enabled().await?; let rfkill = read_rfkill(); let hardware_enabled = reconcile_hardware(nm_hw, rfkill.wlan_hard_block, "wifi"); - let present = has_device_of_type(conn, device_type::WIFI).await; + let present = match present_device_types { + Some(types) => types.contains(&device_type::WIFI), + None => has_device_of_type(conn, device_type::WIFI).await, + }; Ok(RadioState::with_presence( enabled, @@ -44,14 +55,23 @@ pub(crate) async fn wifi_state(conn: &Connection) -> Result { } /// Reads WWAN radio state from NetworkManager, cross-referenced with rfkill. -pub(crate) async fn wwan_state(conn: &Connection) -> Result { +/// +/// If `present_device_types` is provided, uses it to determine whether a modem +/// device exists; otherwise queries NetworkManager directly. +pub(crate) async fn wwan_state( + conn: &Connection, + present_device_types: Option<&HashSet>, +) -> Result { let nm = NMProxy::new(conn).await?; let enabled = nm.wwan_enabled().await?; let nm_hw = nm.wwan_hardware_enabled().await?; let rfkill = read_rfkill(); let hardware_enabled = reconcile_hardware(nm_hw, rfkill.wwan_hard_block, "wwan"); - let present = has_device_of_type(conn, device_type::MODEM).await; + let present = match present_device_types { + Some(types) => types.contains(&device_type::MODEM), + None => has_device_of_type(conn, device_type::MODEM).await, + }; Ok(RadioState::with_presence( enabled, @@ -103,10 +123,15 @@ pub(crate) async fn bluetooth_radio_state(conn: &Connection) -> Result Result { + let present_types = fetch_present_device_types(conn).await; + let (wifi, wwan, bt) = futures::future::join3( - wifi_state(conn), - wwan_state(conn), + wifi_state(conn, Some(&present_types)), + wwan_state(conn, Some(&present_types)), bluetooth_radio_state(conn), ) .await; @@ -129,10 +154,13 @@ pub(crate) async fn set_wwan_enabled(conn: &Connection, enabled: bool) -> Result /// Enables or disables Bluetooth radio by toggling all BlueZ adapters. /// /// After writing `Powered` we wait up to [`BLUEZ_POWER_SETTLE_TIMEOUT`] for -/// the adapter's reported state to actually flip. Otherwise a consumer that +/// 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. /// +/// Adapters are toggled concurrently with a single overall timeout to keep +/// latency bounded regardless of adapter count. +/// /// If BlueZ is not running, returns [`ConnectionError::BluezUnavailable`]. pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool) -> Result<()> { let adapter_paths = enumerate_bluetooth_adapters(conn).await.map_err(|e| { @@ -145,31 +173,45 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool )); } - let mut first_err: Option = None; - for path in &adapter_paths { - let result: Result<()> = async { - let proxy = BluezAdapterProxy::builder(conn) - .path(path.as_str())? - .build() - .await?; - proxy.set_powered(enabled).await?; - wait_for_powered(&proxy, enabled, BLUEZ_POWER_SETTLE_TIMEOUT).await; - Ok(()) - } - .await; + // Build proxies and toggle power concurrently + let toggle_futures = adapter_paths.iter().map(|path| async move { + let proxy_result = BluezAdapterProxy::builder(conn) + .path(path.as_str()) + .ok()? + .build() + .await + .ok()?; - if let Err(e) = result { + if let Err(e) = proxy_result.set_powered(enabled).await { warn!("failed to set Powered on {}: {}", path, e); - if first_err.is_none() { - first_err = Some(e); - } + return None; } - } - match first_err { - Some(e) => Err(e), - None => Ok(()), + Some(proxy_result) + }); + + let results: Vec<_> = futures::future::join_all(toggle_futures).await; + let successful_proxies: Vec<_> = results.into_iter().flatten().collect(); + + if successful_proxies.is_empty() && !adapter_paths.is_empty() { + return Err(ConnectionError::BluezUnavailable( + "failed to toggle any Bluetooth adapters".to_string(), + )); } + + // Wait for all successfully toggled adapters to settle, with a single overall timeout + let wait_futures = successful_proxies + .iter() + .map(|proxy| wait_for_powered_no_timeout(proxy, enabled)); + + let all_waits = futures::future::join_all(wait_futures); + let timer = Delay::new(BLUEZ_POWER_SETTLE_TIMEOUT); + + let all_waits = pin!(all_waits.fuse()); + let timer = pin!(timer.fuse()); + let _ = future::select(all_waits, timer).await; + + Ok(()) } /// Flips all three radios in parallel. @@ -177,7 +219,7 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool /// `enabled = true` means airplane mode **on** (radios **off**). /// Does not fail fast — attempts all three and returns the first error, /// except that a missing Bluetooth stack is not treated as a failure -/// (`BluezUnavailable` is silently downgraded to a no-op). +/// (`BluezUnavailable` is downgraded to a logged no-op). pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Result<()> { let radio_on = !enabled; @@ -193,10 +235,14 @@ pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Resul wwan_res?; match bt_res { Ok(()) => {} - Err(ConnectionError::BluezUnavailable(_)) => { + Err(ConnectionError::BluezUnavailable(message)) => { // No Bluetooth on this host — that's fine, don't fail the whole // call (and don't leave callers thinking the wifi/wwan flips // didn't happen). + warn!( + "Ignoring Bluetooth airplane-mode toggle because BlueZ is unavailable: {}", + message + ); } Err(e) => return Err(e), } @@ -240,18 +286,21 @@ fn reconcile_hardware(nm_hardware_enabled: bool, rfkill_hard_block: bool, radio: nm_hardware_enabled && !rfkill_hard_block } -/// Returns `true` if NetworkManager has at least one device of the given type. +/// Fetches all device types present in NetworkManager. /// -/// Failures (D-Bus error, no NM running) are treated as "no devices of this -/// type" — we'd rather mark a radio as absent than spuriously block the -/// airplane-mode aggregator. -async fn has_device_of_type(conn: &Connection, type_code: u32) -> bool { +/// Queries the device list once and returns a set of device type codes. +/// Failures are treated as "no devices" — we'd rather mark radios as absent +/// than spuriously block the airplane-mode aggregator. +async fn fetch_present_device_types(conn: &Connection) -> HashSet { + let mut types = HashSet::new(); + let Ok(nm) = NMProxy::new(conn).await else { - return false; + return types; }; let Ok(paths) = nm.get_devices().await else { - return false; + return types; }; + for p in paths { let Ok(builder) = NMDeviceProxy::builder(conn).path(p) else { continue; @@ -259,13 +308,24 @@ async fn has_device_of_type(conn: &Connection, type_code: u32) -> bool { let Ok(dev) = builder.build().await else { continue; }; - if let Ok(t) = dev.device_type().await - && t == type_code - { - return true; + if let Ok(t) = dev.device_type().await { + types.insert(t); } } - false + + types +} + +/// Returns `true` if NetworkManager has at least one device of the given type. +/// +/// Failures (D-Bus error, no NM running) are treated as "no devices of this +/// type" — we'd rather mark a radio as absent than spuriously block the +/// airplane-mode aggregator. +/// +/// Prefer using [`fetch_present_device_types`] when checking multiple device +/// types to avoid redundant D-Bus round-trips. +async fn has_device_of_type(conn: &Connection, type_code: u32) -> bool { + fetch_present_device_types(conn).await.contains(&type_code) } /// Waits for a BlueZ adapter's `Powered` property to settle on `target`. @@ -273,25 +333,20 @@ async fn has_device_of_type(conn: &Connection, type_code: u32) -> bool { /// Subscribes to `PropertiesChanged` on `Powered` first, then re-reads the /// current value (so we don't miss a fast transition that happened between /// the `set_powered` write and the subscription). Returns when the property -/// matches `target` or when `timeout` elapses (whichever comes first). -async fn wait_for_powered(proxy: &BluezAdapterProxy<'_>, target: bool, timeout: Duration) { +/// matches `target`. This variant has no timeout — use with an external +/// timeout wrapper when waiting on multiple adapters concurrently. +async fn wait_for_powered_no_timeout(proxy: &BluezAdapterProxy<'_>, target: bool) { let mut stream = proxy.receive_powered_changed().await; if proxy.powered().await.unwrap_or(target) == target { return; } - let watcher = async { - while let Some(change) = stream.next().await { - if let Ok(value) = change.get().await - && value == target - { - return; - } + while let Some(change) = stream.next().await { + if let Ok(value) = change.get().await + && value == target + { + return; } - }; - - let watcher = pin!(watcher.fuse()); - let timer = pin!(Delay::new(timeout).fuse()); - let _ = future::select(watcher, timer).await; + } } From f2b5051c319d18841535a7e96747d7af8fc94c82 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 8 May 2026 15:57:12 -0400 Subject: [PATCH 3/9] chore: update CHANGELOG --- nmrs/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index e8f2dcb0..032c6cfa 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -10,20 +10,20 @@ All notable changes to the `nmrs` crate will be documented in this file. - `RadioState::present` indicates whether a controllable instance of the radio exists on the host. `RadioState::with_presence(enabled, hardware_enabled, present)` constructor; `RadioState::new` keeps existing behavior and defaults - `present = true`. + `present = true`.([#396](https://github.com/cachebag/nmrs/issues/396)) ### Fixed - `AirplaneModeState::is_airplane_mode` no longer returns `false` on hosts without Bluetooth/WWAN. Radios reported with `present = false` are now - ignored when computing both `is_airplane_mode` and `any_hardware_killed`. + ignored when computing both `is_airplane_mode` and `any_hardware_killed`. ([#396](https://github.com/cachebag/nmrs/pull/396)) - `set_airplane_mode` no longer returns `BluezUnavailable` (and therefore no longer leaves Wi-Fi soft-killed while reporting failure to the caller) when the host has no Bluetooth stack. A missing BlueZ is treated as a successful - no-op for the Bluetooth leg of the toggle. + no-op for the Bluetooth leg of the toggle. ([#396](https://github.com/cachebag/nmrs/pull/396)) - `set_bluetooth_radio_enabled` waits up to 2s for each adapter's `Powered` property to actually flip before returning, so a read-after-write of `airplane_mode_state()` no longer observes the pre-toggle Bluetooth state - and concludes that airplane mode failed to engage. + and concludes that airplane mode failed to engage. ([#396](https://github.com/cachebag/nmrs/pull/396)) ## [3.0.1] - 2026-04-25 ### Changed From 40d48e63e6c14204de18402c1994261516d3ada2 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 8 May 2026 16:09:48 -0400 Subject: [PATCH 4/9] fix(airplane): assume radios present on device-list errors and split Bluetooth toggle vs stack-unavailable errors --- nmrs/src/api/models/error.rs | 6 +- nmrs/src/api/network_manager.rs | 3 +- nmrs/src/core/airplane.rs | 97 +++++++++++++++++---------------- 3 files changed, 56 insertions(+), 50 deletions(-) diff --git a/nmrs/src/api/models/error.rs b/nmrs/src/api/models/error.rs index 1c6c0370..e4082338 100644 --- a/nmrs/src/api/models/error.rs +++ b/nmrs/src/api/models/error.rs @@ -244,10 +244,14 @@ pub enum ConnectionError { #[error("radio is hardware-disabled (rfkill)")] HardwareRadioKilled, - /// The BlueZ Bluetooth stack is unavailable. + /// The BlueZ Bluetooth stack is unavailable (not running or no adapters). #[error("bluetooth stack unavailable: {0}")] BluezUnavailable(String), + /// Bluetooth adapters exist but toggling them failed. + #[error("bluetooth toggle failed: {0}")] + BluetoothToggleFailed(String), + /// Invalid VLAN ID (must be 1-4094). #[error("invalid VLAN ID {id}: must be between 1 and 4094")] InvalidVlanId { diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 94f30c35..371df757 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -778,7 +778,8 @@ impl NetworkManager { /// stack (BlueZ not running or no adapters) is treated as a successful /// no-op rather than as an error so that flipping airplane mode on a /// wifi-only host still succeeds and leaves the toggle in the expected - /// state. + /// state. However, if Bluetooth adapters exist but fail to toggle + /// (e.g., D-Bus errors), that error is propagated. pub async fn set_airplane_mode(&self, enabled: bool) -> Result<()> { airplane::set_airplane_mode(&self.conn, enabled).await } diff --git a/nmrs/src/core/airplane.rs b/nmrs/src/core/airplane.rs index 7001b10a..7d497165 100644 --- a/nmrs/src/core/airplane.rs +++ b/nmrs/src/core/airplane.rs @@ -30,8 +30,9 @@ const BLUEZ_POWER_SETTLE_TIMEOUT: Duration = Duration::from_secs(2); /// Reads Wi-Fi radio state from NetworkManager, cross-referenced with rfkill. /// -/// If `present_device_types` is provided, uses it to determine whether a Wi-Fi -/// device exists; otherwise queries NetworkManager directly. +/// If `present_device_types` is `Some(set)`, uses the set to determine whether +/// a Wi-Fi device exists. If `None`, assumes the radio is present (used when +/// the device list couldn't be fetched). pub(crate) async fn wifi_state( conn: &Connection, present_device_types: Option<&HashSet>, @@ -44,7 +45,7 @@ pub(crate) async fn wifi_state( let hardware_enabled = reconcile_hardware(nm_hw, rfkill.wlan_hard_block, "wifi"); let present = match present_device_types { Some(types) => types.contains(&device_type::WIFI), - None => has_device_of_type(conn, device_type::WIFI).await, + None => true, // Assume present if we couldn't fetch device list }; Ok(RadioState::with_presence( @@ -56,8 +57,9 @@ pub(crate) async fn wifi_state( /// Reads WWAN radio state from NetworkManager, cross-referenced with rfkill. /// -/// If `present_device_types` is provided, uses it to determine whether a modem -/// device exists; otherwise queries NetworkManager directly. +/// If `present_device_types` is `Some(set)`, uses the set to determine whether +/// a modem device exists. If `None`, assumes the radio is present (used when +/// the device list couldn't be fetched). pub(crate) async fn wwan_state( conn: &Connection, present_device_types: Option<&HashSet>, @@ -70,7 +72,7 @@ pub(crate) async fn wwan_state( let hardware_enabled = reconcile_hardware(nm_hw, rfkill.wwan_hard_block, "wwan"); let present = match present_device_types { Some(types) => types.contains(&device_type::MODEM), - None => has_device_of_type(conn, device_type::MODEM).await, + None => true, // Assume present if we couldn't fetch device list }; Ok(RadioState::with_presence( @@ -125,13 +127,14 @@ pub(crate) async fn bluetooth_radio_state(conn: &Connection) -> Result Result { let present_types = fetch_present_device_types(conn).await; let (wifi, wwan, bt) = futures::future::join3( - wifi_state(conn, Some(&present_types)), - wwan_state(conn, Some(&present_types)), + wifi_state(conn, present_types.as_ref()), + wwan_state(conn, present_types.as_ref()), bluetooth_radio_state(conn), ) .await; @@ -161,7 +164,12 @@ pub(crate) async fn set_wwan_enabled(conn: &Connection, enabled: bool) -> Result /// Adapters are toggled concurrently with a single overall timeout to keep /// latency bounded regardless of adapter count. /// -/// If BlueZ is not running, returns [`ConnectionError::BluezUnavailable`]. +/// # Errors +/// +/// - [`ConnectionError::BluezUnavailable`] if BlueZ is not running or no +/// adapters exist. +/// - [`ConnectionError::BluetoothToggleFailed`] if adapters exist but none +/// could be toggled (e.g., D-Bus errors on all adapters). pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool) -> Result<()> { let adapter_paths = enumerate_bluetooth_adapters(conn).await.map_err(|e| { ConnectionError::BluezUnavailable(format!("failed to enumerate adapters: {e}")) @@ -175,26 +183,33 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool // Build proxies and toggle power concurrently let toggle_futures = adapter_paths.iter().map(|path| async move { - let proxy_result = BluezAdapterProxy::builder(conn) - .path(path.as_str()) - .ok()? - .build() - .await - .ok()?; + let proxy = match BluezAdapterProxy::builder(conn).path(path.as_str()) { + Ok(builder) => match builder.build().await { + Ok(proxy) => proxy, + Err(e) => { + warn!("failed to build proxy for adapter {}: {}", path, e); + return None; + } + }, + Err(e) => { + warn!("invalid adapter path {}: {}", path, e); + return None; + } + }; - if let Err(e) = proxy_result.set_powered(enabled).await { + if let Err(e) = proxy.set_powered(enabled).await { warn!("failed to set Powered on {}: {}", path, e); return None; } - Some(proxy_result) + Some(proxy) }); let results: Vec<_> = futures::future::join_all(toggle_futures).await; let successful_proxies: Vec<_> = results.into_iter().flatten().collect(); if successful_proxies.is_empty() && !adapter_paths.is_empty() { - return Err(ConnectionError::BluezUnavailable( + return Err(ConnectionError::BluetoothToggleFailed( "failed to toggle any Bluetooth adapters".to_string(), )); } @@ -218,8 +233,9 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool /// /// `enabled = true` means airplane mode **on** (radios **off**). /// Does not fail fast — attempts all three and returns the first error, -/// except that a missing Bluetooth stack is not treated as a failure -/// (`BluezUnavailable` is downgraded to a logged no-op). +/// except that a missing Bluetooth stack (BlueZ not running or no adapters) +/// is treated as a successful no-op. If Bluetooth adapters exist but fail +/// to toggle, that error is propagated. pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Result<()> { let radio_on = !enabled; @@ -236,14 +252,15 @@ pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Resul match bt_res { Ok(()) => {} Err(ConnectionError::BluezUnavailable(message)) => { - // No Bluetooth on this host — that's fine, don't fail the whole - // call (and don't leave callers thinking the wifi/wwan flips - // didn't happen). + // No Bluetooth on this host (BlueZ not running or no adapters) — + // that's fine, don't fail the whole call. warn!( "Ignoring Bluetooth airplane-mode toggle because BlueZ is unavailable: {}", message ); } + // BluetoothToggleFailed means adapters exist but couldn't be toggled — + // that's a real failure, propagate it. Err(e) => return Err(e), } Ok(()) @@ -289,18 +306,14 @@ fn reconcile_hardware(nm_hardware_enabled: bool, rfkill_hard_block: bool, radio: /// Fetches all device types present in NetworkManager. /// /// Queries the device list once and returns a set of device type codes. -/// Failures are treated as "no devices" — we'd rather mark radios as absent -/// than spuriously block the airplane-mode aggregator. -async fn fetch_present_device_types(conn: &Connection) -> HashSet { - let mut types = HashSet::new(); - - let Ok(nm) = NMProxy::new(conn).await else { - return types; - }; - let Ok(paths) = nm.get_devices().await else { - return types; - }; +/// Returns `None` if the device list could not be fetched (NM unavailable, +/// D-Bus error), signaling that callers should assume radios are present +/// rather than incorrectly marking them absent. +async fn fetch_present_device_types(conn: &Connection) -> Option> { + let nm = NMProxy::new(conn).await.ok()?; + let paths = nm.get_devices().await.ok()?; + let mut types = HashSet::new(); for p in paths { let Ok(builder) = NMDeviceProxy::builder(conn).path(p) else { continue; @@ -313,19 +326,7 @@ async fn fetch_present_device_types(conn: &Connection) -> HashSet { } } - types -} - -/// Returns `true` if NetworkManager has at least one device of the given type. -/// -/// Failures (D-Bus error, no NM running) are treated as "no devices of this -/// type" — we'd rather mark a radio as absent than spuriously block the -/// airplane-mode aggregator. -/// -/// Prefer using [`fetch_present_device_types`] when checking multiple device -/// types to avoid redundant D-Bus round-trips. -async fn has_device_of_type(conn: &Connection, type_code: u32) -> bool { - fetch_present_device_types(conn).await.contains(&type_code) + Some(types) } /// Waits for a BlueZ adapter's `Powered` property to settle on `target`. From 2ef234e19c3f5edb1bd4cb63e304af5a97f979e6 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 8 May 2026 16:18:58 -0400 Subject: [PATCH 5/9] chore: update CHANGELOG --- nmrs/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 032c6cfa..7a34ec8d 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -13,6 +13,9 @@ All notable changes to the `nmrs` crate will be documented in this file. `present = true`.([#396](https://github.com/cachebag/nmrs/issues/396)) ### Fixed +- `NetworkManager::wifi_state` and `wwan_state` now set `RadioState::present` + from NetworkManager device enumeration (with `present = true` when + enumeration is incomplete), instead of always reporting present. ([#396](https://github.com/cachebag/nmrs/pull/396)) - `AirplaneModeState::is_airplane_mode` no longer returns `false` on hosts without Bluetooth/WWAN. Radios reported with `present = false` are now ignored when computing both `is_airplane_mode` and `any_hardware_killed`. ([#396](https://github.com/cachebag/nmrs/pull/396)) @@ -20,7 +23,7 @@ All notable changes to the `nmrs` crate will be documented in this file. longer leaves Wi-Fi soft-killed while reporting failure to the caller) when the host has no Bluetooth stack. A missing BlueZ is treated as a successful no-op for the Bluetooth leg of the toggle. ([#396](https://github.com/cachebag/nmrs/pull/396)) -- `set_bluetooth_radio_enabled` waits up to 2s for each adapter's `Powered` +- `set_bluetooth_radio_enabled` waits up to 2s overall for adapters' `Powered` property to actually flip before returning, so a read-after-write of `airplane_mode_state()` no longer observes the pre-toggle Bluetooth state and concludes that airplane mode failed to engage. ([#396](https://github.com/cachebag/nmrs/pull/396)) From cd4ca86279005dfe130b4e005584100cb4471737 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 8 May 2026 16:19:05 -0400 Subject: [PATCH 6/9] fix(airplane): enumerate devices for wifi/wwan present, strict NM fetch, and fix Powered read on settle --- nmrs/src/api/network_manager.rs | 14 +++++++++++--- nmrs/src/core/airplane.rs | 28 +++++++++++++--------------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 371df757..9e42cf16 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -708,14 +708,22 @@ impl NetworkManager { /// Returns the combined software/hardware state of the Wi-Fi radio. /// /// See [`RadioState`] for the distinction between `enabled` (software) - /// and `hardware_enabled` (rfkill). + /// and `hardware_enabled` (rfkill). The `present` flag reflects whether + /// NetworkManager has a Wi-Fi device object; if device enumeration fails, + /// `present` defaults to `true` so callers are not misled into thinking + /// Wi-Fi is absent. pub async fn wifi_state(&self) -> Result { - airplane::wifi_state(&self.conn, None).await + let present_types = airplane::fetch_present_device_types(&self.conn).await; + airplane::wifi_state(&self.conn, present_types.as_ref()).await } /// Returns the combined software/hardware state of the WWAN radio. + /// + /// The `present` flag reflects whether NetworkManager has a modem device + /// object; if device enumeration fails, `present` defaults to `true`. pub async fn wwan_state(&self) -> Result { - airplane::wwan_state(&self.conn, None).await + let present_types = airplane::fetch_present_device_types(&self.conn).await; + airplane::wwan_state(&self.conn, present_types.as_ref()).await } /// Returns the combined software/hardware state of the Bluetooth radio. diff --git a/nmrs/src/core/airplane.rs b/nmrs/src/core/airplane.rs index 7d497165..5ca31c93 100644 --- a/nmrs/src/core/airplane.rs +++ b/nmrs/src/core/airplane.rs @@ -303,27 +303,23 @@ fn reconcile_hardware(nm_hardware_enabled: bool, rfkill_hard_block: bool, radio: nm_hardware_enabled && !rfkill_hard_block } -/// Fetches all device types present in NetworkManager. +/// Fetches all device types present in NetworkManager device objects. /// /// Queries the device list once and returns a set of device type codes. -/// Returns `None` if the device list could not be fetched (NM unavailable, -/// D-Bus error), signaling that callers should assume radios are present -/// rather than incorrectly marking them absent. -async fn fetch_present_device_types(conn: &Connection) -> Option> { +/// Returns `None` if the device list could not be fetched at all (NM +/// unavailable, `GetDevices` failed) or if any enumerated device could not +/// be introspected (incomplete enumeration), signaling that callers should +/// assume radios are present rather than risk a false negative. +pub(crate) async fn fetch_present_device_types(conn: &Connection) -> Option> { let nm = NMProxy::new(conn).await.ok()?; let paths = nm.get_devices().await.ok()?; let mut types = HashSet::new(); for p in paths { - let Ok(builder) = NMDeviceProxy::builder(conn).path(p) else { - continue; - }; - let Ok(dev) = builder.build().await else { - continue; - }; - if let Ok(t) = dev.device_type().await { - types.insert(t); - } + let builder = NMDeviceProxy::builder(conn).path(p).ok()?; + let dev = builder.build().await.ok()?; + let t = dev.device_type().await.ok()?; + types.insert(t); } Some(types) @@ -339,7 +335,9 @@ async fn fetch_present_device_types(conn: &Connection) -> Option> { async fn wait_for_powered_no_timeout(proxy: &BluezAdapterProxy<'_>, target: bool) { let mut stream = proxy.receive_powered_changed().await; - if proxy.powered().await.unwrap_or(target) == target { + if let Ok(value) = proxy.powered().await + && value == target + { return; } From affa544ac9e82c4595071f51286675567981a705 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 8 May 2026 16:25:51 -0400 Subject: [PATCH 7/9] update CHANGELOG --- nmrs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 7a34ec8d..ed954663 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to the `nmrs` crate will be documented in this file. - `RadioState::present` indicates whether a controllable instance of the radio exists on the host. `RadioState::with_presence(enabled, hardware_enabled, present)` constructor; `RadioState::new` keeps existing behavior and defaults - `present = true`.([#396](https://github.com/cachebag/nmrs/issues/396)) + `present = true`. ([#396](https://github.com/cachebag/nmrs/issues/396)) ### Fixed - `NetworkManager::wifi_state` and `wwan_state` now set `RadioState::present` From a92909f31766e0f04cc8e825d89e315b7711bf7b Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 8 May 2026 16:26:29 -0400 Subject: [PATCH 8/9] fix: simplified condition --- nmrs/src/api/network_manager.rs | 5 +++-- nmrs/src/core/airplane.rs | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 9e42cf16..a4b3d54b 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -786,8 +786,9 @@ impl NetworkManager { /// stack (BlueZ not running or no adapters) is treated as a successful /// no-op rather than as an error so that flipping airplane mode on a /// wifi-only host still succeeds and leaves the toggle in the expected - /// state. However, if Bluetooth adapters exist but fail to toggle - /// (e.g., D-Bus errors), that error is propagated. + /// state. However, if Bluetooth adapters exist but no adapter could be + /// toggled successfully (e.g., due to D-Bus errors), that error is + /// propagated. pub async fn set_airplane_mode(&self, enabled: bool) -> Result<()> { airplane::set_airplane_mode(&self.conn, enabled).await } diff --git a/nmrs/src/core/airplane.rs b/nmrs/src/core/airplane.rs index 5ca31c93..7d507fe4 100644 --- a/nmrs/src/core/airplane.rs +++ b/nmrs/src/core/airplane.rs @@ -208,7 +208,7 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool let results: Vec<_> = futures::future::join_all(toggle_futures).await; let successful_proxies: Vec<_> = results.into_iter().flatten().collect(); - if successful_proxies.is_empty() && !adapter_paths.is_empty() { + if successful_proxies.is_empty() { return Err(ConnectionError::BluetoothToggleFailed( "failed to toggle any Bluetooth adapters".to_string(), )); @@ -234,8 +234,9 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool /// `enabled = true` means airplane mode **on** (radios **off**). /// Does not fail fast — attempts all three and returns the first error, /// except that a missing Bluetooth stack (BlueZ not running or no adapters) -/// is treated as a successful no-op. If Bluetooth adapters exist but fail -/// to toggle, that error is propagated. +/// is treated as a successful no-op. If Bluetooth adapters exist but no +/// adapter could be toggled successfully (e.g., due to D-Bus errors), that +/// error is propagated. pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Result<()> { let radio_on = !enabled; @@ -259,8 +260,7 @@ pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Resul message ); } - // BluetoothToggleFailed means adapters exist but couldn't be toggled — - // that's a real failure, propagate it. + // BluetoothToggleFailed means no adapter could be toggled — propagate. Err(e) => return Err(e), } Ok(()) From dc841e799f418900e2dee8d5ccc4ccc39bf895e1 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 8 May 2026 16:34:53 -0400 Subject: [PATCH 9/9] fix(airplane): fail Bluetooth toggle unless every adapter succeeds and settles --- nmrs/src/api/network_manager.rs | 7 ++--- nmrs/src/core/airplane.rs | 48 ++++++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index a4b3d54b..33e8c036 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -772,7 +772,8 @@ impl NetworkManager { /// Enables or disables the Bluetooth radio by toggling all BlueZ adapters. /// /// Returns [`BluezUnavailable`](crate::ConnectionError::BluezUnavailable) if BlueZ is not running - /// or no adapters exist. + /// or no adapters exist, or [`BluetoothToggleFailed`](crate::ConnectionError::BluetoothToggleFailed) + /// if any adapter could not be toggled or did not reach the requested power state. pub async fn set_bluetooth_radio_enabled(&self, enabled: bool) -> Result<()> { airplane::set_bluetooth_radio_enabled(&self.conn, enabled).await } @@ -786,8 +787,8 @@ impl NetworkManager { /// stack (BlueZ not running or no adapters) is treated as a successful /// no-op rather than as an error so that flipping airplane mode on a /// wifi-only host still succeeds and leaves the toggle in the expected - /// state. However, if Bluetooth adapters exist but no adapter could be - /// toggled successfully (e.g., due to D-Bus errors), that error is + /// state. However, if Bluetooth adapters exist but any adapter could not + /// be toggled or did not reach the expected power state, that error is /// propagated. pub async fn set_airplane_mode(&self, enabled: bool) -> Result<()> { airplane::set_airplane_mode(&self.conn, enabled).await diff --git a/nmrs/src/core/airplane.rs b/nmrs/src/core/airplane.rs index 7d507fe4..06225d91 100644 --- a/nmrs/src/core/airplane.rs +++ b/nmrs/src/core/airplane.rs @@ -168,8 +168,9 @@ pub(crate) async fn set_wwan_enabled(conn: &Connection, enabled: bool) -> Result /// /// - [`ConnectionError::BluezUnavailable`] if BlueZ is not running or no /// adapters exist. -/// - [`ConnectionError::BluetoothToggleFailed`] if adapters exist but none -/// could be toggled (e.g., D-Bus errors on all adapters). +/// - [`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). pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool) -> Result<()> { let adapter_paths = enumerate_bluetooth_adapters(conn).await.map_err(|e| { ConnectionError::BluezUnavailable(format!("failed to enumerate adapters: {e}")) @@ -181,6 +182,8 @@ 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()) { @@ -206,15 +209,18 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool }); let results: Vec<_> = futures::future::join_all(toggle_futures).await; - let successful_proxies: Vec<_> = results.into_iter().flatten().collect(); - - if successful_proxies.is_empty() { - return Err(ConnectionError::BluetoothToggleFailed( - "failed to toggle any Bluetooth adapters".to_string(), - )); + let n_ok = results.iter().filter(|r| r.is_some()).count(); + if n_ok != n_adapters { + return Err(ConnectionError::BluetoothToggleFailed(format!( + "failed to toggle {} of {} Bluetooth adapter(s)", + n_adapters.saturating_sub(n_ok), + n_adapters + ))); } - // Wait for all successfully toggled adapters to settle, with a single overall timeout + 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)); @@ -226,6 +232,22 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool let timer = pin!(timer.fuse()); let _ = future::select(all_waits, timer).await; + for proxy in &successful_proxies { + match proxy.powered().await { + Ok(v) if v == enabled => {} + Ok(_) => { + return Err(ConnectionError::BluetoothToggleFailed( + "Bluetooth adapter Powered did not reach requested state in time".to_string(), + )); + } + Err(e) => { + return Err(ConnectionError::BluetoothToggleFailed(format!( + "could not read Powered after toggle: {e}" + ))); + } + } + } + Ok(()) } @@ -234,9 +256,9 @@ pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool /// `enabled = true` means airplane mode **on** (radios **off**). /// Does not fail fast — attempts all three and returns the first error, /// except that a missing Bluetooth stack (BlueZ not running or no adapters) -/// is treated as a successful no-op. If Bluetooth adapters exist but no -/// adapter could be toggled successfully (e.g., due to D-Bus errors), that -/// error is propagated. +/// is treated as a successful no-op. If Bluetooth adapters exist but any of +/// them could not be toggled or did not report the expected `Powered` state, +/// that error is propagated. pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Result<()> { let radio_on = !enabled; @@ -260,7 +282,7 @@ pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Resul message ); } - // BluetoothToggleFailed means no adapter could be toggled — propagate. + // BluetoothToggleFailed — at least one adapter failed or wrong state. Err(e) => return Err(e), } Ok(())