From a5b47e3a31008911d96a1b9f8c34b992eda5feef Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 15 May 2026 18:53:37 -0400 Subject: [PATCH 1/2] feat(#402): add mmrs high-level ModemManager API --- mmrs/src/api/mod.rs | 10 +- mmrs/src/api/models/error.rs | 4 + mmrs/src/api/models/mod.rs | 2 +- mmrs/src/api/models/modem.rs | 23 ++ mmrs/src/api/modem_manager.rs | 542 ++++++++++++++++++++++++++++++++++ mmrs/src/api/modem_scope.rs | 103 +++++++ mmrs/src/lib.rs | 24 +- 7 files changed, 695 insertions(+), 13 deletions(-) create mode 100644 mmrs/src/api/modem_manager.rs create mode 100644 mmrs/src/api/modem_scope.rs diff --git a/mmrs/src/api/mod.rs b/mmrs/src/api/mod.rs index 7c1feeda..9b1fb937 100644 --- a/mmrs/src/api/mod.rs +++ b/mmrs/src/api/mod.rs @@ -1,7 +1,11 @@ //! Public-facing API surface for the `mmrs` crate. //! -//! Currently exposes the [`models`] sub-module; higher-level helpers -//! (entry-point `ModemManager` struct, builders, etc.) will land here as -//! the crate grows. +//! Exposes the high-level [`ModemManager`] entry point, scoped +//! [`ModemScope`] operations, and the [`models`] sub-module. pub mod models; +mod modem_manager; +mod modem_scope; + +pub use modem_manager::ModemManager; +pub use modem_scope::ModemScope; diff --git a/mmrs/src/api/models/error.rs b/mmrs/src/api/models/error.rs index ca43ce33..9cc4af5f 100644 --- a/mmrs/src/api/models/error.rs +++ b/mmrs/src/api/models/error.rs @@ -32,6 +32,10 @@ pub enum ModemError { #[error("d-bus error: {0}")] Dbus(#[from] zbus::Error), + /// A standard freedesktop.org D-Bus interface operation failed. + #[error("d-bus fdo error: {0}")] + DbusFdo(#[from] zbus::fdo::Error), + /// A D-Bus operation failed, with context about what was being attempted. #[error("{context}: {source}")] DbusOperation { diff --git a/mmrs/src/api/models/mod.rs b/mmrs/src/api/models/mod.rs index 70030102..efb009a9 100644 --- a/mmrs/src/api/models/mod.rs +++ b/mmrs/src/api/models/mod.rs @@ -16,5 +16,5 @@ mod sim; pub use bearer::{Bearer, BearerConfig, BearerStats, Ip4Config, IpType}; pub use error::{ModemError, Result}; -pub use modem::{AccessTechnology, Modem, ModemState}; +pub use modem::{AccessTechnology, ConnectionStatus, Modem, ModemState}; pub use sim::{Sim, SimLockState}; diff --git a/mmrs/src/api/models/modem.rs b/mmrs/src/api/models/modem.rs index ef5b0251..dc9a8ee7 100644 --- a/mmrs/src/api/models/modem.rs +++ b/mmrs/src/api/models/modem.rs @@ -496,6 +496,29 @@ pub struct Modem { pub bearer_paths: Vec, } +/// Snapshot of a modem's current packet-data connection status. +/// +/// Produced by [`crate::ModemManager::status`] and +/// [`crate::ModemScope::status`]. It combines the fields most callers need +/// when deciding whether a modem is ready, connected, and using a usable radio +/// technology. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConnectionStatus { + /// D-Bus object path of the modem this status belongs to. + pub modem_path: String, + /// Current modem state. + pub state: ModemState, + /// Whether the modem currently has an active packet-data bearer. + pub connected: bool, + /// Current radio access technology bitmask. + pub access_technology: AccessTechnology, + /// Signal quality percentage when ModemManager reports it. + pub signal_quality: Option, + /// D-Bus object paths of bearers owned by this modem. + pub bearer_paths: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/mmrs/src/api/modem_manager.rs b/mmrs/src/api/modem_manager.rs new file mode 100644 index 00000000..e0afd95b --- /dev/null +++ b/mmrs/src/api/modem_manager.rs @@ -0,0 +1,542 @@ +//! High-level ModemManager entry point. + +use std::collections::HashMap; +use std::net::Ipv4Addr; + +use zbus::Connection; +use zvariant::{OwnedObjectPath, OwnedValue, Str, Value}; + +use crate::api::models::{ + AccessTechnology, Bearer, BearerConfig, BearerStats, ConnectionStatus, Ip4Config, Modem, + ModemError, ModemState, Result, Sim, +}; +use crate::api::modem_scope::ModemScope; +use crate::dbus::{MMBearerProxy, MMManagerProxy, MMModemProxy, MMModemSimpleProxy, MMSimProxy}; + +const MODEM_MANAGER_SERVICE: &str = "org.freedesktop.ModemManager1"; +const MODEM_MANAGER_PATH: &str = "/org/freedesktop/ModemManager1"; +const MODEM_INTERFACE: &str = "org.freedesktop.ModemManager1.Modem"; + +/// High-level interface to ModemManager over D-Bus. +/// +/// This is the main entry point for enumerating modems, managing simple +/// packet-data connections, querying signal state, and working with SIM PINs. +#[derive(Debug, Clone)] +pub struct ModemManager { + conn: Connection, +} + +impl ModemManager { + /// Connects to the system D-Bus and creates a new [`ModemManager`]. + pub async fn new() -> Result { + let conn = Connection::system().await?; + Self::with_connection(conn).await + } + + /// Creates a [`ModemManager`] from an existing D-Bus connection. + pub async fn with_connection(conn: Connection) -> Result { + MMManagerProxy::new(&conn).await?; + Ok(Self { conn }) + } + + /// Returns the underlying D-Bus connection. + pub fn connection(&self) -> &Connection { + &self.conn + } + + /// Lists all modems currently managed by ModemManager. + pub async fn list_modems(&self) -> Result> { + let paths = enumerate_modem_paths(&self.conn).await?; + let mut modems = Vec::with_capacity(paths.len()); + + for path in paths { + modems.push(self.modem_info_for_path(path.as_str()).await?); + } + + Ok(modems) + } + + /// Returns the modem whose equipment identifier matches the given IMEI. + pub async fn modem_by_imei(&self, imei: &str) -> Result { + self.list_modems() + .await? + .into_iter() + .find(|modem| modem.equipment_identifier == imei) + .ok_or_else(|| ModemError::ModemNotFound(format!("IMEI {imei}"))) + } + + /// Returns the first modem reported by ModemManager. + pub async fn primary_modem(&self) -> Result { + let mut modems = self.list_modems().await?; + if modems.is_empty() { + return Err(ModemError::NoModems); + } + Ok(modems.remove(0)) + } + + /// Creates a scope for operating on a specific modem object path. + #[must_use] + pub fn modem(&self, path: &str) -> ModemScope<'_> { + ModemScope::new(self, path) + } + + /// Enables the primary modem. + pub async fn enable(&self) -> Result<()> { + let path = self.primary_modem_path().await?; + self.enable_for_path(path.as_str()).await + } + + /// Disables the primary modem. + pub async fn disable(&self) -> Result<()> { + let path = self.primary_modem_path().await?; + self.disable_for_path(path.as_str()).await + } + + /// Connects the primary modem using only an APN. + /// + /// Uses `Modem.Simple.Connect`, which lets ModemManager handle the + /// one-shot enable, registration, and bearer connection flow. + pub async fn connect_simple(&self, apn: &str) -> Result { + self.connect(&BearerConfig::new(apn)).await + } + + /// Connects the primary modem using a full bearer configuration. + pub async fn connect(&self, config: &BearerConfig) -> Result { + let path = self.primary_modem_path().await?; + self.connect_for_path(path.as_str(), config).await + } + + /// Disconnects all bearers on the primary modem. + pub async fn disconnect(&self) -> Result<()> { + let path = self.primary_modem_path().await?; + self.disconnect_for_path(path.as_str()).await + } + + /// Returns the primary modem's current connection status. + pub async fn status(&self) -> Result { + let path = self.primary_modem_path().await?; + self.status_for_path(path.as_str()).await + } + + /// Returns the primary modem's active SIM, if one is reported. + pub async fn sim(&self) -> Result> { + let path = self.primary_modem_path().await?; + self.sim_for_path(path.as_str()).await + } + + /// Sends a PIN to unlock the primary modem's SIM. + pub async fn unlock_pin(&self, pin: &str) -> Result<()> { + let path = self.primary_modem_path().await?; + self.unlock_pin_for_path(path.as_str(), pin).await + } + + /// Sends a PUK and new PIN to unlock the primary modem's SIM. + pub async fn unlock_puk(&self, puk: &str, new_pin: &str) -> Result<()> { + let path = self.primary_modem_path().await?; + self.unlock_puk_for_path(path.as_str(), puk, new_pin).await + } + + /// Enables or disables SIM PIN checking on the primary modem. + pub async fn set_pin_enabled(&self, pin: &str, enabled: bool) -> Result<()> { + let path = self.primary_modem_path().await?; + self.set_pin_enabled_for_path(path.as_str(), pin, enabled) + .await + } + + /// Changes the primary modem SIM's PIN. + pub async fn change_pin(&self, old: &str, new: &str) -> Result<()> { + let path = self.primary_modem_path().await?; + self.change_pin_for_path(path.as_str(), old, new).await + } + + /// Returns the primary modem's current signal quality percentage. + pub async fn signal_quality(&self) -> Result { + let path = self.primary_modem_path().await?; + self.signal_quality_for_path(path.as_str()).await + } + + /// Returns the primary modem's current access technology bitmask. + pub async fn access_technology(&self) -> Result { + let path = self.primary_modem_path().await?; + self.access_technology_for_path(path.as_str()).await + } + + pub(crate) async fn modem_info_for_path(&self, path: &str) -> Result { + let modem_path = modem_object_path(path)?; + let proxy = MMModemProxy::builder(&self.conn) + .path(modem_path.clone())? + .build() + .await?; + + let (signal_quality, _) = proxy.signal_quality().await?; + let sim_path = proxy.sim().await?; + let bearer_paths = proxy + .bearers() + .await? + .into_iter() + .map(|path| path.to_string()) + .collect(); + + Ok(Modem { + path: modem_path.to_string(), + state: ModemState::from_raw(proxy.state().await?), + manufacturer: proxy.manufacturer().await?, + model: proxy.model().await?, + equipment_identifier: proxy.equipment_identifier().await?, + access_technologies: AccessTechnology::from(proxy.access_technologies().await?), + signal_quality, + primary_sim_path: object_path_option(&sim_path), + bearer_paths, + }) + } + + pub(crate) async fn enable_for_path(&self, path: &str) -> Result<()> { + let proxy = modem_proxy(&self.conn, path).await?; + proxy.enable(true).await?; + Ok(()) + } + + pub(crate) async fn disable_for_path(&self, path: &str) -> Result<()> { + let proxy = modem_proxy(&self.conn, path).await?; + proxy.enable(false).await?; + Ok(()) + } + + pub(crate) async fn connect_for_path( + &self, + path: &str, + config: &BearerConfig, + ) -> Result { + if config.apn.trim().is_empty() { + return Err(ModemError::InvalidApn(config.apn.clone())); + } + + let proxy = modem_simple_proxy(&self.conn, path).await?; + let bearer_path = proxy + .connect(bearer_properties(config)) + .await + .map_err(|e| ModemError::BearerCreationFailed(format!("Simple.Connect failed: {e}")))?; + + bearer_snapshot(&self.conn, &bearer_path).await + } + + pub(crate) async fn disconnect_for_path(&self, path: &str) -> Result<()> { + let proxy = modem_simple_proxy(&self.conn, path).await?; + let all_bearers = OwnedObjectPath::try_from("/").map_err(|e| { + ModemError::BearerDisconnectFailed(format!("invalid all-bearers path: {e}")) + })?; + + proxy.disconnect(all_bearers).await.map_err(|e| { + ModemError::BearerDisconnectFailed(format!("Simple.Disconnect failed: {e}")) + }) + } + + pub(crate) async fn status_for_path(&self, path: &str) -> Result { + let simple = modem_simple_proxy(&self.conn, path).await?; + let status = simple.get_status().await?; + let modem = self.modem_info_for_path(path).await?; + + let state = take_i32(&status, "state") + .map(ModemState::from_raw) + .unwrap_or(modem.state); + let access_technology = take_u32(&status, "access-technology") + .or_else(|| take_u32(&status, "access-technologies")) + .map(AccessTechnology::from) + .unwrap_or(modem.access_technologies); + let signal_quality = take_u32(&status, "signal-quality").or(Some(modem.signal_quality)); + + Ok(ConnectionStatus { + modem_path: modem.path, + state, + connected: state.is_connected(), + access_technology, + signal_quality, + bearer_paths: modem.bearer_paths, + }) + } + + pub(crate) async fn sim_for_path(&self, path: &str) -> Result> { + let modem = modem_proxy(&self.conn, path).await?; + let sim_path = modem.sim().await?; + if object_path_option(&sim_path).is_none() { + return Ok(None); + } + + let proxy = MMSimProxy::builder(&self.conn) + .path(sim_path.clone())? + .build() + .await?; + + Ok(Some(Sim { + path: sim_path.to_string(), + active: proxy.active().await?, + iccid: proxy.sim_identifier().await?, + imsi: proxy.imsi().await?, + operator_name: proxy.operator_name().await?, + })) + } + + pub(crate) async fn unlock_pin_for_path(&self, path: &str, pin: &str) -> Result<()> { + let sim = sim_proxy_for_modem(&self.conn, path).await?; + sim.send_pin(pin).await.map_err(|e| { + if is_wrong_pin_error(&e) { + ModemError::WrongPin + } else { + ModemError::Dbus(e) + } + }) + } + + pub(crate) async fn unlock_puk_for_path( + &self, + path: &str, + puk: &str, + new_pin: &str, + ) -> Result<()> { + let sim = sim_proxy_for_modem(&self.conn, path).await?; + sim.send_puk(puk, new_pin).await.map_err(|e| { + if is_wrong_puk_error(&e) { + ModemError::WrongPuk + } else { + ModemError::Dbus(e) + } + }) + } + + pub(crate) async fn set_pin_enabled_for_path( + &self, + path: &str, + pin: &str, + enabled: bool, + ) -> Result<()> { + let sim = sim_proxy_for_modem(&self.conn, path).await?; + sim.enable_pin(pin, enabled).await.map_err(|e| { + if is_wrong_pin_error(&e) { + ModemError::WrongPin + } else { + ModemError::Dbus(e) + } + }) + } + + pub(crate) async fn change_pin_for_path(&self, path: &str, old: &str, new: &str) -> Result<()> { + let sim = sim_proxy_for_modem(&self.conn, path).await?; + sim.change_pin(old, new).await.map_err(|e| { + if is_wrong_pin_error(&e) { + ModemError::WrongPin + } else { + ModemError::Dbus(e) + } + }) + } + + pub(crate) async fn signal_quality_for_path(&self, path: &str) -> Result { + let proxy = modem_proxy(&self.conn, path).await?; + let (quality, _) = proxy.signal_quality().await?; + Ok(quality) + } + + pub(crate) async fn access_technology_for_path(&self, path: &str) -> Result { + let proxy = modem_proxy(&self.conn, path).await?; + Ok(AccessTechnology::from(proxy.access_technologies().await?)) + } + + async fn primary_modem_path(&self) -> Result { + enumerate_modem_paths(&self.conn) + .await? + .into_iter() + .next() + .ok_or(ModemError::NoModems) + } +} + +async fn enumerate_modem_paths(conn: &Connection) -> Result> { + let manager = zbus::fdo::ObjectManagerProxy::builder(conn) + .destination(MODEM_MANAGER_SERVICE)? + .path(MODEM_MANAGER_PATH)? + .build() + .await?; + + let objects = manager.get_managed_objects().await?; + let mut paths: Vec = objects + .into_iter() + .filter(|(_, ifaces)| ifaces.contains_key(MODEM_INTERFACE)) + .map(|(path, _)| path.to_string()) + .collect(); + paths.sort(); + Ok(paths) +} + +async fn modem_proxy<'a>(conn: &'a Connection, path: &str) -> Result> { + Ok(MMModemProxy::builder(conn) + .path(modem_object_path(path)?)? + .build() + .await?) +} + +async fn modem_simple_proxy<'a>( + conn: &'a Connection, + path: &str, +) -> Result> { + Ok(MMModemSimpleProxy::builder(conn) + .path(modem_object_path(path)?)? + .build() + .await?) +} + +async fn sim_proxy_for_modem<'a>(conn: &'a Connection, path: &str) -> Result> { + let modem = modem_proxy(conn, path).await?; + let sim_path = modem.sim().await?; + if object_path_option(&sim_path).is_none() { + return Err(ModemError::NoSim); + } + + Ok(MMSimProxy::builder(conn).path(sim_path)?.build().await?) +} + +async fn bearer_snapshot(conn: &Connection, path: &OwnedObjectPath) -> Result { + let proxy = MMBearerProxy::builder(conn) + .path(path.clone())? + .build() + .await?; + let ip4 = proxy.ip4_config().await?; + let stats = proxy.stats().await?; + + Ok(Bearer { + path: path.to_string(), + interface: proxy.interface().await?, + connected: proxy.connected().await?, + ip4_config: decode_ip4_config(&ip4), + stats: decode_bearer_stats(&stats), + }) +} + +fn bearer_properties(config: &BearerConfig) -> HashMap<&str, Value<'_>> { + let mut properties = HashMap::new(); + properties.insert("apn", Value::from(config.apn.as_str())); + properties.insert("ip-type", Value::from(config.ip_type.as_raw())); + properties.insert("allow-roaming", Value::from(config.allow_roaming)); + + if let Some(user) = &config.user { + properties.insert("user", Value::from(user.as_str())); + } + if let Some(password) = &config.password { + properties.insert("password", Value::from(password.as_str())); + } + + properties +} + +fn modem_object_path(path: &str) -> Result { + OwnedObjectPath::try_from(path) + .map_err(|e| ModemError::ModemNotFound(format!("{path} (invalid D-Bus object path: {e})"))) +} + +fn object_path_option(path: &OwnedObjectPath) -> Option { + let path = path.to_string(); + if path == "/" { None } else { Some(path) } +} + +fn decode_ip4_config(values: &HashMap) -> Option { + if values.is_empty() { + return None; + } + + Some(Ip4Config { + method: take_str(values, "method") + .or_else(|| take_u32(values, "method").map(|value| value.to_string())) + .unwrap_or_default(), + address: take_str(values, "address").and_then(|value| value.parse().ok()), + prefix: take_u32(values, "prefix").unwrap_or_default(), + gateway: take_str(values, "gateway").and_then(|value| value.parse().ok()), + dns: take_ipv4_vec(values, "dns"), + mtu: take_u32(values, "mtu"), + }) +} + +fn decode_bearer_stats(values: &HashMap) -> BearerStats { + BearerStats { + rx_bytes: take_u64(values, "rx-bytes").unwrap_or_default(), + tx_bytes: take_u64(values, "tx-bytes").unwrap_or_default(), + duration_seconds: take_u32(values, "duration").unwrap_or_default(), + attempts: take_u32(values, "attempts").unwrap_or_default(), + failed_attempts: take_u32(values, "failed-attempts").unwrap_or_default(), + total_duration_seconds: take_u32(values, "total-duration").unwrap_or_default(), + total_rx_bytes: take_u64(values, "total-rx-bytes").unwrap_or_default(), + total_tx_bytes: take_u64(values, "total-tx-bytes").unwrap_or_default(), + } +} + +fn take_str(values: &HashMap, key: &str) -> Option { + values.get(key).and_then(owned_to_str) +} + +fn take_u32(values: &HashMap, key: &str) -> Option { + values.get(key).and_then(owned_to_u32) +} + +fn take_i32(values: &HashMap, key: &str) -> Option { + values.get(key).and_then(owned_to_i32) +} + +fn take_u64(values: &HashMap, key: &str) -> Option { + values.get(key).and_then(owned_to_u64) +} + +fn take_ipv4_vec(values: &HashMap, key: &str) -> Vec { + let Some(value) = values.get(key) else { + return Vec::new(); + }; + + if let Ok(strings) = Vec::::try_from(value.clone()) { + return strings + .into_iter() + .filter_map(|value| value.parse().ok()) + .collect(); + } + + if let Ok(numbers) = Vec::::try_from(value.clone()) { + return numbers.into_iter().map(Ipv4Addr::from).collect(); + } + + Vec::new() +} + +fn owned_to_str(value: &OwnedValue) -> Option { + Str::try_from(value.clone()) + .ok() + .map(|value| value.to_string()) + .or_else(|| String::try_from(value.clone()).ok()) +} + +fn owned_to_u32(value: &OwnedValue) -> Option { + u32::try_from(value.clone()).ok().or_else(|| { + i32::try_from(value.clone()) + .ok() + .and_then(|value| value.try_into().ok()) + }) +} + +fn owned_to_i32(value: &OwnedValue) -> Option { + i32::try_from(value.clone()).ok().or_else(|| { + u32::try_from(value.clone()) + .ok() + .and_then(|value| value.try_into().ok()) + }) +} + +fn owned_to_u64(value: &OwnedValue) -> Option { + u64::try_from(value.clone()) + .ok() + .or_else(|| u32::try_from(value.clone()).ok().map(u64::from)) +} + +fn is_wrong_pin_error(error: &zbus::Error) -> bool { + let rendered = error.to_string().to_ascii_lowercase(); + rendered.contains("wrong") && rendered.contains("pin") +} + +fn is_wrong_puk_error(error: &zbus::Error) -> bool { + let rendered = error.to_string().to_ascii_lowercase(); + rendered.contains("wrong") && rendered.contains("puk") +} diff --git a/mmrs/src/api/modem_scope.rs b/mmrs/src/api/modem_scope.rs new file mode 100644 index 00000000..ca9eb417 --- /dev/null +++ b/mmrs/src/api/modem_scope.rs @@ -0,0 +1,103 @@ +//! Per-modem scoped high-level API. + +use crate::api::models::{ + AccessTechnology, Bearer, BearerConfig, ConnectionStatus, Modem, Result, Sim, +}; +use crate::api::modem_manager::ModemManager; + +/// Operations scoped to a single ModemManager modem object path. +/// +/// Create this with [`ModemManager::modem`] when a system has multiple modems +/// and the default primary-modem behavior is not specific enough. +#[derive(Debug)] +pub struct ModemScope<'a> { + pub(crate) mm: &'a ModemManager, + pub(crate) path: String, +} + +impl<'a> ModemScope<'a> { + pub(crate) fn new(mm: &'a ModemManager, path: &str) -> Self { + Self { + mm, + path: path.to_string(), + } + } + + /// Returns the scoped modem object path. + #[must_use] + pub fn path(&self) -> &str { + &self.path + } + + /// Returns a snapshot of this modem. + pub async fn info(&self) -> Result { + self.mm.modem_info_for_path(&self.path).await + } + + /// Enables this modem. + pub async fn enable(&self) -> Result<()> { + self.mm.enable_for_path(&self.path).await + } + + /// Disables this modem. + pub async fn disable(&self) -> Result<()> { + self.mm.disable_for_path(&self.path).await + } + + /// Connects this modem using only an APN. + pub async fn connect_simple(&self, apn: &str) -> Result { + self.connect(&BearerConfig::new(apn)).await + } + + /// Connects this modem using a full bearer configuration. + pub async fn connect(&self, config: &BearerConfig) -> Result { + self.mm.connect_for_path(&self.path, config).await + } + + /// Disconnects all bearers on this modem. + pub async fn disconnect(&self) -> Result<()> { + self.mm.disconnect_for_path(&self.path).await + } + + /// Returns this modem's current connection status. + pub async fn status(&self) -> Result { + self.mm.status_for_path(&self.path).await + } + + /// Returns this modem's active SIM, if one is reported. + pub async fn sim(&self) -> Result> { + self.mm.sim_for_path(&self.path).await + } + + /// Sends a PIN to unlock this modem's SIM. + pub async fn unlock_pin(&self, pin: &str) -> Result<()> { + self.mm.unlock_pin_for_path(&self.path, pin).await + } + + /// Sends a PUK and new PIN to unlock this modem's SIM. + pub async fn unlock_puk(&self, puk: &str, new_pin: &str) -> Result<()> { + self.mm.unlock_puk_for_path(&self.path, puk, new_pin).await + } + + /// Enables or disables SIM PIN checking on this modem. + pub async fn set_pin_enabled(&self, pin: &str, enabled: bool) -> Result<()> { + self.mm + .set_pin_enabled_for_path(&self.path, pin, enabled) + .await + } + + /// Changes this modem SIM's PIN. + pub async fn change_pin(&self, old: &str, new: &str) -> Result<()> { + self.mm.change_pin_for_path(&self.path, old, new).await + } + + /// Returns this modem's current signal quality percentage. + pub async fn signal_quality(&self) -> Result { + self.mm.signal_quality_for_path(&self.path).await + } + + /// Returns this modem's current access technology bitmask. + pub async fn access_technology(&self) -> Result { + self.mm.access_technology_for_path(&self.path).await + } +} diff --git a/mmrs/src/lib.rs b/mmrs/src/lib.rs index 5049134a..25e5d9aa 100644 --- a/mmrs/src/lib.rs +++ b/mmrs/src/lib.rs @@ -1,10 +1,8 @@ //! Rust bindings for [ModemManager](https://modemmanager.org/) over D-Bus. //! -//! This crate is in early development. The currently stable surface is the -//! set of public **model types** that describe modems, SIMs, and packet-data -//! bearers as exposed by ModemManager. Higher-level helpers -//! (connect / disconnect, monitoring, builders) will land on top of these -//! types in subsequent releases. +//! This crate is in early development. The public surface includes the +//! high-level [`ModemManager`] entry point plus model types that describe +//! modems, SIMs, and packet-data bearers as exposed by ModemManager. //! //! # Modules //! @@ -23,8 +21,13 @@ //! //! # Example //! -//! ```rust -//! use mmrs::{AccessTechnology, BearerConfig, IpType, ModemState}; +//! ```no_run +//! use mmrs::{AccessTechnology, BearerConfig, IpType, ModemManager, ModemState}; +//! +//! # async fn example() -> mmrs::Result<()> { +//! let mm = ModemManager::new().await?; +//! let modems = mm.list_modems().await?; +//! # let _ = modems; //! //! let state = ModemState::from_raw(11); //! assert!(state.is_connected()); @@ -37,6 +40,8 @@ //! .with_user("user") //! .with_password("hunter2"); //! assert_eq!(cfg.apn, "internet"); +//! # Ok(()) +//! # } //! ``` pub mod api; @@ -53,6 +58,7 @@ pub mod models { } pub use api::models::{ - AccessTechnology, Bearer, BearerConfig, BearerStats, Ip4Config, IpType, Modem, ModemError, - ModemState, Result, Sim, SimLockState, + AccessTechnology, Bearer, BearerConfig, BearerStats, ConnectionStatus, Ip4Config, IpType, + Modem, ModemError, ModemState, Result, Sim, SimLockState, }; +pub use api::{ModemManager, ModemScope}; From 349b3c4366e4987531fa196947c1885fddaa93fc Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 15 May 2026 18:53:40 -0400 Subject: [PATCH 2/2] docs(#402): document mmrs high-level API --- mmrs/CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mmrs/CHANGELOG.md b/mmrs/CHANGELOG.md index 9c3e70bf..1b721740 100644 --- a/mmrs/CHANGELOG.md +++ b/mmrs/CHANGELOG.md @@ -6,9 +6,13 @@ All notable changes to the `mmrs` crate will be documented in this file. ### Added +- High-level `ModemManager` entry point with modem enumeration, primary-modem + connection helpers, SIM PIN operations, signal queries, and per-modem + `ModemScope` support (#402). +- `ConnectionStatus` snapshot model for `ModemManager::status` and + `ModemScope::status` (#402). - Public model types for the ModemManager domain under `mmrs::models`: `Modem`, `ModemState`, `AccessTechnology`, `Sim`, `SimLockState`, `Bearer`, `BearerConfig`, `BearerStats`, `Ip4Config`, `IpType`, `ModemError`, and the `Result` alias. All public structs and enums are `#[non_exhaustive]`; `BearerConfig` ships with `with_*` builder methods. -