diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 9a9797ad..ed954663 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -6,6 +6,28 @@ 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`. ([#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)) +- `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. ([#396](https://github.com/cachebag/nmrs/pull/396)) +- `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)) + ## [3.0.1] - 2026-04-25 ### Changed - Lower MSRV from 1.94.0 to 1.90.0 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/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..33e8c036 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -708,21 +708,30 @@ 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).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).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. /// /// 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 +739,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 +753,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 } @@ -754,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 } @@ -764,7 +783,13 @@ 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. 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 f27df3d3..06225d91 100644 --- a/nmrs/src/core/airplane.rs +++ b/nmrs/src/core/airplane.rs @@ -2,49 +2,96 @@ //! //! 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::collections::HashSet; +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 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 `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>, +) -> 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 = match present_device_types { + Some(types) => types.contains(&device_type::WIFI), + None => true, // Assume present if we couldn't fetch device list + }; - Ok(RadioState::new(enabled, hardware_enabled)) + Ok(RadioState::with_presence( + enabled, + hardware_enabled, + present, + )) } /// Reads WWAN radio state from NetworkManager, cross-referenced with rfkill. -pub(crate) async fn wwan_state(conn: &Connection) -> Result { +/// +/// 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>, +) -> 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 = match present_device_types { + Some(types) => types.contains(&device_type::MODEM), + None => true, // Assume present if we couldn't fetch device list + }; - 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,14 +117,24 @@ 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, present_types.as_ref()), + wwan_state(conn, present_types.as_ref()), bluetooth_radio_state(conn), ) .await; @@ -99,7 +156,21 @@ pub(crate) async fn set_wwan_enabled(conn: &Connection, enabled: bool) -> 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 +/// 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. +/// +/// # 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). 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}")) @@ -111,36 +182,83 @@ 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?; - Ok(()) - } - .await; + let n_adapters = adapter_paths.len(); - if let Err(e) = result { - warn!("failed to set Powered on {}: {}", path, e); - if first_err.is_none() { - first_err = Some(e); + // 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 { + 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.set_powered(enabled).await { + warn!("failed to set Powered on {}: {}", path, e); + return None; } + + Some(proxy) + }); + + let results: Vec<_> = futures::future::join_all(toggle_futures).await; + 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 + ))); } - match first_err { - Some(e) => Err(e), - None => Ok(()), + 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)); + + 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; + + 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(()) } /// 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 (BlueZ not running or no adapters) +/// 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; @@ -154,7 +272,19 @@ 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(message)) => { + // 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 — at least one adapter failed or wrong state. + Err(e) => return Err(e), + } Ok(()) } @@ -194,3 +324,50 @@ 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 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 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 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) +} + +/// 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`. 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 let Ok(value) = proxy.powered().await + && value == target + { + return; + } + + while let Some(change) = stream.next().await { + if let Ok(value) = change.get().await + && value == target + { + return; + } + } +} 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; }