Skip to content
22 changes: 22 additions & 0 deletions nmrs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion nmrs/src/api/models/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
116 changes: 108 additions & 8 deletions nmrs/src/api/models/radio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@
//! 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.
///
/// `enabled` reflects the user-facing toggle (can be written via D-Bus).
/// `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 {
Expand All @@ -19,22 +27,48 @@ 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,
}
}
}

/// 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 {
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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());
}
}
41 changes: 33 additions & 8 deletions nmrs/src/api/network_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -708,29 +708,42 @@ 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<RadioState> {
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<RadioState> {
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
}
Comment thread
cachebag marked this conversation as resolved.

/// 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<RadioState> {
airplane::bluetooth_radio_state(&self.conn).await
}

/// 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<AirplaneModeState> {
airplane::airplane_mode_state(&self.conn).await
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
Loading