diff --git a/mmrs/CHANGELOG.md b/mmrs/CHANGELOG.md index ba13edc9..9c3e70bf 100644 --- a/mmrs/CHANGELOG.md +++ b/mmrs/CHANGELOG.md @@ -3,3 +3,12 @@ All notable changes to the `mmrs` crate will be documented in this file. ## [Unreleased] + +### Added + +- 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. + diff --git a/mmrs/Cargo.toml b/mmrs/Cargo.toml index f7a56629..af9bbde1 100644 --- a/mmrs/Cargo.toml +++ b/mmrs/Cargo.toml @@ -11,5 +11,6 @@ categories = ["api-bindings", "asynchronous"] [dependencies] log.workspace = true +thiserror.workspace = true zbus.workspace = true zvariant.workspace = true diff --git a/mmrs/src/api/mod.rs b/mmrs/src/api/mod.rs index 8b137891..7c1feeda 100644 --- a/mmrs/src/api/mod.rs +++ b/mmrs/src/api/mod.rs @@ -1 +1,7 @@ +//! 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. +pub mod models; diff --git a/mmrs/src/api/models/bearer.rs b/mmrs/src/api/models/bearer.rs new file mode 100644 index 00000000..bbb4f609 --- /dev/null +++ b/mmrs/src/api/models/bearer.rs @@ -0,0 +1,382 @@ +//! Bearer-level public types. +//! +//! [`Bearer`] is the runtime snapshot of an active or pending data +//! connection, while [`BearerConfig`] is the input passed when asking the +//! modem to create or activate a bearer. + +use std::fmt; +use std::net::Ipv4Addr; + +/// IP family preference for a bearer connection. +/// +/// Maps from `MM_BEARER_IP_FAMILY_*` bits used when creating a bearer. +/// +/// | Raw value | Constant | Variant | +/// |-----------|-----------------------------------|-----------| +/// | 0 | `MM_BEARER_IP_FAMILY_NONE` | `None` | +/// | 1 | `MM_BEARER_IP_FAMILY_IPV4` | `Ipv4` | +/// | 2 | `MM_BEARER_IP_FAMILY_IPV6` | `Ipv6` | +/// | 4 | `MM_BEARER_IP_FAMILY_IPV4V6` | `Ipv4v6` | +/// | `0xFFFF_FFFF` | `MM_BEARER_IP_FAMILY_ANY` | `Any` | +/// +/// # Example +/// +/// ```rust +/// use mmrs::IpType; +/// +/// assert_eq!(IpType::from_raw(1), IpType::Ipv4); +/// assert!(IpType::Ipv4v6.is_dual_stack()); +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum IpType { + /// No IP family is requested. + None, + /// IPv4 only. + #[default] + Ipv4, + /// IPv6 only. + Ipv6, + /// Dual-stack IPv4 + IPv6. + Ipv4v6, + /// Any family — let the network decide. + Any, +} + +impl IpType { + /// Decode from the raw `MM_BEARER_IP_FAMILY_*` value. + /// + /// Returns [`IpType::None`] for `0` and unrecognised values. + #[must_use] + pub const fn from_raw(value: u32) -> Self { + match value { + 1 => Self::Ipv4, + 2 => Self::Ipv6, + 4 => Self::Ipv4v6, + u32::MAX => Self::Any, + _ => Self::None, + } + } + + /// Returns the raw `MM_BEARER_IP_FAMILY_*` constant. + #[must_use] + pub const fn as_raw(self) -> u32 { + match self { + Self::None => 0, + Self::Ipv4 => 1, + Self::Ipv6 => 2, + Self::Ipv4v6 => 4, + Self::Any => u32::MAX, + } + } + + /// Returns `true` if this family requests both IPv4 and IPv6. + #[must_use] + pub const fn is_dual_stack(self) -> bool { + matches!(self, Self::Ipv4v6) + } +} + +impl From for IpType { + fn from(value: u32) -> Self { + Self::from_raw(value) + } +} + +impl fmt::Display for IpType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::None => "none", + Self::Ipv4 => "ipv4", + Self::Ipv6 => "ipv6", + Self::Ipv4v6 => "ipv4v6", + Self::Any => "any", + }; + f.write_str(s) + } +} + +/// IPv4 configuration reported on an active bearer. +/// +/// Decoded from the `Bearer.Ip4Config` dictionary. [`method`](Ip4Config::method) +/// is always present in this struct but may be an empty string when the +/// dictionary omits or does not carry `method`; [`address`](Ip4Config::address), +/// [`gateway`](Ip4Config::gateway), and [`mtu`](Ip4Config::mtu) use +/// [`Option`]; [`prefix`](Ip4Config::prefix) defaults to `0` when not reported; +/// [`dns`](Ip4Config::dns) is empty when none were supplied. +/// External callers receive `Ip4Config` from the higher-level API; the +/// example below shows how to inspect such an instance. +/// +/// # Example +/// +/// ```rust +/// use mmrs::Ip4Config; +/// +/// fn print_address(cfg: &Ip4Config) { +/// if let Some(addr) = cfg.address { +/// println!("address: {}/{}", addr, cfg.prefix); +/// } +/// for dns in &cfg.dns { +/// println!("dns: {dns}"); +/// } +/// } +/// # let _ = print_address; +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Ip4Config { + /// Configuration method reported by ModemManager + /// (typically `"ppp"`, `"static"`, or `"dhcp"`). + pub method: String, + /// Bearer interface IPv4 address. + pub address: Option, + /// CIDR prefix length (e.g. `24` for `/24`). + pub prefix: u32, + /// Default gateway, if any. + pub gateway: Option, + /// DNS servers in priority order (typically up to three). + pub dns: Vec, + /// Path MTU when reported. + pub mtu: Option, +} + +/// Connection statistics for a bearer. +/// +/// Decoded from the `Bearer.Stats` dictionary. Counters covering only the +/// current session live in the unqualified fields (`rx_bytes`, +/// `tx_bytes`, `duration_seconds`); cumulative counters across reconnects +/// (where the modem reports them) live in the `total_*` fields. +/// +/// # Example +/// +/// ```rust +/// use mmrs::BearerStats; +/// +/// fn throughput_kib_per_s(stats: &BearerStats) -> f64 { +/// if stats.duration_seconds == 0 { +/// return 0.0; +/// } +/// let bytes = stats.rx_bytes + stats.tx_bytes; +/// (bytes as f64 / 1024.0) / stats.duration_seconds as f64 +/// } +/// # let _ = throughput_kib_per_s; +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct BearerStats { + /// Bytes received during the current connection. + pub rx_bytes: u64, + /// Bytes sent during the current connection. + pub tx_bytes: u64, + /// Duration of the current connection, in seconds. + pub duration_seconds: u32, + /// Number of connection attempts (current session). + pub attempts: u32, + /// Number of failed connection attempts (current session). + pub failed_attempts: u32, + /// Cumulative connected duration across sessions, in seconds. + pub total_duration_seconds: u32, + /// Cumulative bytes received across sessions. + pub total_rx_bytes: u64, + /// Cumulative bytes sent across sessions. + pub total_tx_bytes: u64, +} + +/// Snapshot of a single packet-data bearer owned by a modem. +/// +/// Mirrors the `org.freedesktop.ModemManager1.Bearer` D-Bus interface. +/// Instances are produced by the higher-level `mmrs` APIs; the example +/// below shows the inspection-only side of the type. +/// +/// # Example +/// +/// ```rust +/// use mmrs::Bearer; +/// +/// fn summary(bearer: &Bearer) -> String { +/// let state = if bearer.connected { "up" } else { "down" }; +/// format!("{} ({}): {}", bearer.interface, state, bearer.path) +/// } +/// # let _ = summary; +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bearer { + /// D-Bus object path of the bearer + /// (e.g. `/org/freedesktop/ModemManager1/Bearer/0`). + pub path: String, + /// Network interface name once the bearer is connected + /// (`Interface` property, e.g. `"wwan0"`). + pub interface: String, + /// Whether the bearer is currently connected (`Connected` property). + pub connected: bool, + /// IPv4 configuration when the bearer is active and IPv4 is enabled. + pub ip4_config: Option, + /// Connection statistics reported by ModemManager. + pub stats: BearerStats, +} + +/// Configuration passed to `Modem.CreateBearer` (or `Simple.Connect`). +/// +/// Use [`BearerConfig::new`] to start from a required APN and chain +/// [`with_user`](Self::with_user) / [`with_password`](Self::with_password) +/// / [`with_ip_type`](Self::with_ip_type) / +/// [`with_allow_roaming`](Self::with_allow_roaming) to fill optional fields. +/// +/// # Example +/// +/// ```rust +/// use mmrs::{BearerConfig, IpType}; +/// +/// let cfg = BearerConfig::new("internet") +/// .with_user("user") +/// .with_password("hunter2") +/// .with_ip_type(IpType::Ipv4v6) +/// .with_allow_roaming(true); +/// +/// assert_eq!(cfg.apn, "internet"); +/// assert_eq!(cfg.ip_type, IpType::Ipv4v6); +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BearerConfig { + /// Access Point Name (`apn` key) — required by virtually every carrier. + pub apn: String, + /// Requested IP family (`ip-type` key). + pub ip_type: IpType, + /// Optional username for PAP/CHAP auth (`user` key). + pub user: Option, + /// Optional password for PAP/CHAP auth (`password` key). + pub password: Option, + /// Allow data while roaming (`allow-roaming` key). Defaults to `false`. + pub allow_roaming: bool, +} + +impl BearerConfig { + /// Creates a new [`BearerConfig`] with the given APN and sensible defaults + /// (`Ipv4`, no credentials, roaming disabled). + /// + /// # Example + /// + /// ```rust + /// use mmrs::BearerConfig; + /// + /// let cfg = BearerConfig::new("hologram"); + /// assert_eq!(cfg.apn, "hologram"); + /// assert!(!cfg.allow_roaming); + /// ``` + pub fn new(apn: impl Into) -> Self { + Self { + apn: apn.into(), + ip_type: IpType::Ipv4, + user: None, + password: None, + allow_roaming: false, + } + } + + /// Sets the requested IP family. + #[must_use] + pub fn with_ip_type(mut self, ip_type: IpType) -> Self { + self.ip_type = ip_type; + self + } + + /// Sets the username for PAP/CHAP authentication. + #[must_use] + pub fn with_user(mut self, user: impl Into) -> Self { + self.user = Some(user.into()); + self + } + + /// Sets the password for PAP/CHAP authentication. + #[must_use] + pub fn with_password(mut self, password: impl Into) -> Self { + self.password = Some(password.into()); + self + } + + /// Allows or disallows data while roaming. + #[must_use] + pub fn with_allow_roaming(mut self, allow: bool) -> Self { + self.allow_roaming = allow; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ip_type_round_trip() { + for variant in [ + IpType::None, + IpType::Ipv4, + IpType::Ipv6, + IpType::Ipv4v6, + IpType::Any, + ] { + assert_eq!(IpType::from_raw(variant.as_raw()), variant); + } + } + + #[test] + fn ip_type_default_is_ipv4() { + assert_eq!(IpType::default(), IpType::Ipv4); + } + + #[test] + fn ip_type_unknown_maps_to_none() { + assert_eq!(IpType::from_raw(99), IpType::None); + } + + #[test] + fn ip_type_dual_stack_predicate() { + assert!(IpType::Ipv4v6.is_dual_stack()); + assert!(!IpType::Ipv4.is_dual_stack()); + assert!(!IpType::Any.is_dual_stack()); + } + + #[test] + fn bearer_config_new_has_defaults() { + let cfg = BearerConfig::new("hologram"); + assert_eq!(cfg.apn, "hologram"); + assert_eq!(cfg.ip_type, IpType::Ipv4); + assert!(cfg.user.is_none()); + assert!(cfg.password.is_none()); + assert!(!cfg.allow_roaming); + } + + #[test] + fn bearer_config_builders_set_fields() { + let cfg = BearerConfig::new("internet") + .with_user("u") + .with_password("p") + .with_ip_type(IpType::Ipv4v6) + .with_allow_roaming(true); + + assert_eq!(cfg.user.as_deref(), Some("u")); + assert_eq!(cfg.password.as_deref(), Some("p")); + assert_eq!(cfg.ip_type, IpType::Ipv4v6); + assert!(cfg.allow_roaming); + } + + #[test] + fn bearer_stats_default_is_zeroed() { + let stats = BearerStats::default(); + assert_eq!(stats.rx_bytes, 0); + assert_eq!(stats.tx_bytes, 0); + assert_eq!(stats.duration_seconds, 0); + assert_eq!(stats.total_rx_bytes, 0); + } + + #[test] + fn ip4_config_default_is_empty() { + let cfg = Ip4Config::default(); + assert!(cfg.method.is_empty()); + assert!(cfg.address.is_none()); + assert_eq!(cfg.prefix, 0); + assert!(cfg.dns.is_empty()); + } +} diff --git a/mmrs/src/api/models/error.rs b/mmrs/src/api/models/error.rs new file mode 100644 index 00000000..ca43ce33 --- /dev/null +++ b/mmrs/src/api/models/error.rs @@ -0,0 +1,149 @@ +//! Error types for ModemManager operations. + +use thiserror::Error; + +use super::sim::SimLockState; + +/// Errors that can occur during ModemManager operations. +/// +/// All fallible operations in `mmrs` return [`Result`]. +/// +/// # Examples +/// +/// ```rust +/// use mmrs::{ModemError, SimLockState}; +/// +/// fn handle(err: ModemError) { +/// match err { +/// ModemError::NoModems => eprintln!("no modems available"), +/// ModemError::SimLocked(lock) => { +/// eprintln!("SIM is locked: {:?}", lock); +/// } +/// ModemError::WrongPin => eprintln!("incorrect PIN"), +/// other => eprintln!("modem error: {other}"), +/// } +/// } +/// # handle(ModemError::SimLocked(SimLockState::SimPin)); +/// ``` +#[non_exhaustive] +#[derive(Debug, Error)] +pub enum ModemError { + /// A D-Bus communication error occurred. + #[error("d-bus error: {0}")] + Dbus(#[from] zbus::Error), + + /// A D-Bus operation failed, with context about what was being attempted. + #[error("{context}: {source}")] + DbusOperation { + /// Human-readable description of the operation that failed. + context: String, + /// The underlying `zbus` error. + #[source] + source: zbus::Error, + }, + + /// ModemManager reported no managed modems. + #[error("no modems found")] + NoModems, + + /// No modem was found at the requested D-Bus path. + #[error("modem not found: {0}")] + ModemNotFound(String), + + /// The modem is in the failed state and cannot be used. + #[error("modem in failed state: {0}")] + ModemFailed(String), + + /// The modem is currently disabled (call `enable` first). + #[error("modem is disabled")] + ModemDisabled, + + /// No SIM is inserted in the modem (or the slot is empty). + #[error("no SIM present")] + NoSim, + + /// The SIM is locked and requires unlocking before use. + #[error("sim locked: {0:?}")] + SimLocked(SimLockState), + + /// The supplied PIN was incorrect. + #[error("wrong pin")] + WrongPin, + + /// The supplied PUK was incorrect (PIN remains locked). + #[error("wrong puk")] + WrongPuk, + + /// The PIN format was invalid (e.g. non-digit or wrong length). + #[error("invalid pin format: {0}")] + InvalidPin(String), + + /// Bearer creation failed with the given reason. + #[error("bearer creation failed: {0}")] + BearerCreationFailed(String), + + /// No bearer was found at the requested D-Bus path. + #[error("bearer not found: {0}")] + BearerNotFound(String), + + /// Activating the bearer (bringing up the data connection) failed. + #[error("bearer connect failed: {0}")] + BearerConnectFailed(String), + + /// Deactivating the bearer failed. + #[error("bearer disconnect failed: {0}")] + BearerDisconnectFailed(String), + + /// The configured APN was rejected or invalid. + #[error("invalid apn: {0}")] + InvalidApn(String), + + /// The operation timed out waiting for the modem. + #[error("modem operation timed out")] + Timeout, + + /// The operation is not supported by this modem or firmware. + #[error("operation not supported: {0}")] + Unsupported(String), + + /// A string property returned by ModemManager was not valid UTF-8. + #[error("invalid utf-8 in modem property: {0}")] + InvalidUtf8(#[from] std::str::Utf8Error), + + /// An integer property could not be parsed. + #[error("invalid integer in modem property: {0}")] + InvalidInt(#[from] std::num::ParseIntError), +} + +/// Convenience alias for `Result`. +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_includes_context() { + let err = ModemError::ModemNotFound("/org/freedesktop/ModemManager1/Modem/0".into()); + let rendered = err.to_string(); + assert!(rendered.contains("modem not found")); + assert!(rendered.contains("Modem/0")); + } + + #[test] + fn sim_locked_carries_state() { + let err = ModemError::SimLocked(SimLockState::SimPuk); + assert!(err.to_string().contains("SimPuk")); + } + + #[test] + fn dbus_operation_chains_source() { + let err = ModemError::DbusOperation { + context: "calling Enable".into(), + source: zbus::Error::InvalidReply, + }; + let rendered = err.to_string(); + assert!(rendered.contains("calling Enable")); + assert!(std::error::Error::source(&err).is_some()); + } +} diff --git a/mmrs/src/api/models/mod.rs b/mmrs/src/api/models/mod.rs new file mode 100644 index 00000000..70030102 --- /dev/null +++ b/mmrs/src/api/models/mod.rs @@ -0,0 +1,20 @@ +//! Public data types for ModemManager. +//! +//! Each submodule mirrors one part of the ModemManager D-Bus surface: +//! +//! - [`modem`] — [`Modem`], [`ModemState`], [`AccessTechnology`] +//! - [`sim`] — [`Sim`], [`SimLockState`] +//! - [`bearer`] — [`Bearer`], [`BearerConfig`], [`BearerStats`], [`Ip4Config`], [`IpType`] +//! - [`error`] — [`ModemError`] and the crate's [`Result`] alias +//! +//! The types are re-exported at the crate root for convenience. + +mod bearer; +mod error; +mod modem; +mod sim; + +pub use bearer::{Bearer, BearerConfig, BearerStats, Ip4Config, IpType}; +pub use error::{ModemError, Result}; +pub use modem::{AccessTechnology, Modem, ModemState}; +pub use sim::{Sim, SimLockState}; diff --git a/mmrs/src/api/models/modem.rs b/mmrs/src/api/models/modem.rs new file mode 100644 index 00000000..ef5b0251 --- /dev/null +++ b/mmrs/src/api/models/modem.rs @@ -0,0 +1,597 @@ +//! Modem-level public types. +//! +//! Mirrors the ModemManager `org.freedesktop.ModemManager1.Modem` interface: +//! [`ModemState`] decodes the `State` property, [`AccessTechnology`] +//! decodes the `AccessTechnologies` bitmask, and [`Modem`] is the +//! high-level snapshot returned by enumeration APIs. + +use std::fmt; + +/// Lifecycle state of a managed modem. +/// +/// Maps from the `MM_MODEM_STATE_*` constants on the ModemManager +/// [`org.freedesktop.ModemManager1.Modem`] interface. Use +/// [`ModemState::from_raw`] (or the `From` impl) to convert +/// the raw `i32` returned over D-Bus. +/// +/// | Raw value | Constant | Variant | +/// |-----------|-----------------------------------|-----------------| +/// | -1 | `MM_MODEM_STATE_FAILED` | `Failed` | +/// | 0 | `MM_MODEM_STATE_UNKNOWN` | `Unknown` | +/// | 1 | `MM_MODEM_STATE_INITIALIZING` | `Initializing` | +/// | 2 | `MM_MODEM_STATE_LOCKED` | `Locked` | +/// | 3 | `MM_MODEM_STATE_DISABLED` | `Disabled` | +/// | 4 | `MM_MODEM_STATE_DISABLING` | `Disabling` | +/// | 5 | `MM_MODEM_STATE_ENABLING` | `Enabling` | +/// | 6 | `MM_MODEM_STATE_ENABLED` | `Enabled` | +/// | 7 | `MM_MODEM_STATE_SEARCHING` | `Searching` | +/// | 8 | `MM_MODEM_STATE_REGISTERED` | `Registered` | +/// | 9 | `MM_MODEM_STATE_DISCONNECTING` | `Disconnecting` | +/// | 10 | `MM_MODEM_STATE_CONNECTING` | `Connecting` | +/// | 11 | `MM_MODEM_STATE_CONNECTED` | `Connected` | +/// +/// # Example +/// +/// ```rust +/// use mmrs::ModemState; +/// +/// assert_eq!(ModemState::from_raw(8), ModemState::Registered); +/// assert!(ModemState::from_raw(11).is_connected()); +/// assert!(ModemState::from_raw(7).is_searching()); +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ModemState { + /// The modem is in a failed state and cannot be used. + Failed, + /// State is not yet known. + Unknown, + /// The modem is performing initialization checks. + Initializing, + /// The SIM is locked and requires unlocking. + Locked, + /// The modem is administratively disabled. + Disabled, + /// The modem is transitioning from enabled to disabled. + Disabling, + /// The modem is transitioning from disabled to enabled. + Enabling, + /// The modem is enabled but not yet registered. + Enabled, + /// The modem is actively searching for a network. + Searching, + /// The modem is registered with a network but not connected. + Registered, + /// A bearer disconnection is in progress. + Disconnecting, + /// A bearer connection is in progress. + Connecting, + /// A bearer is up and the modem is connected. + Connected, +} + +impl ModemState { + /// Decode the raw `i32` value returned by ModemManager's `State` property. + /// + /// Unknown values map to [`ModemState::Unknown`] so the conversion is + /// total. + #[must_use] + pub const fn from_raw(value: i32) -> Self { + match value { + -1 => Self::Failed, + 1 => Self::Initializing, + 2 => Self::Locked, + 3 => Self::Disabled, + 4 => Self::Disabling, + 5 => Self::Enabling, + 6 => Self::Enabled, + 7 => Self::Searching, + 8 => Self::Registered, + 9 => Self::Disconnecting, + 10 => Self::Connecting, + 11 => Self::Connected, + _ => Self::Unknown, + } + } + + /// Returns the raw ModemManager constant for this state. + #[must_use] + pub const fn as_raw(self) -> i32 { + match self { + Self::Failed => -1, + Self::Unknown => 0, + Self::Initializing => 1, + Self::Locked => 2, + Self::Disabled => 3, + Self::Disabling => 4, + Self::Enabling => 5, + Self::Enabled => 6, + Self::Searching => 7, + Self::Registered => 8, + Self::Disconnecting => 9, + Self::Connecting => 10, + Self::Connected => 11, + } + } + + /// Returns `true` when the modem has an active data bearer. + #[must_use] + pub const fn is_connected(self) -> bool { + matches!(self, Self::Connected) + } + + /// Returns `true` when the modem is registered on a network. + /// + /// This includes [`Disconnecting`](Self::Disconnecting) (tearing down a + /// connection while still attached to the network), plus + /// [`Connecting`](Self::Connecting) and [`Connected`](Self::Connected). + #[must_use] + pub const fn is_registered(self) -> bool { + matches!( + self, + Self::Registered | Self::Disconnecting | Self::Connecting | Self::Connected + ) + } + + /// Returns `true` while the modem is searching for a network. + #[must_use] + pub const fn is_searching(self) -> bool { + matches!(self, Self::Searching) + } + + /// Returns `true` for terminal failure states. + #[must_use] + pub const fn is_failed(self) -> bool { + matches!(self, Self::Failed) + } +} + +impl From for ModemState { + fn from(value: i32) -> Self { + Self::from_raw(value) + } +} + +impl fmt::Display for ModemState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Failed => "failed", + Self::Unknown => "unknown", + Self::Initializing => "initializing", + Self::Locked => "locked", + Self::Disabled => "disabled", + Self::Disabling => "disabling", + Self::Enabling => "enabling", + Self::Enabled => "enabled", + Self::Searching => "searching", + Self::Registered => "registered", + Self::Disconnecting => "disconnecting", + Self::Connecting => "connecting", + Self::Connected => "connected", + }; + f.write_str(s) + } +} + +// Raw bit positions from `MM_MODEM_ACCESS_TECHNOLOGY_*` in ModemManager's +// `mm-enums.h`. Kept as private constants so the public surface stays +// driven by named methods rather than magic numbers. +const AT_POTS: u32 = 1 << 0; +const AT_GSM: u32 = 1 << 1; +const AT_GSM_COMPACT: u32 = 1 << 2; +const AT_GPRS: u32 = 1 << 3; +const AT_EDGE: u32 = 1 << 4; +const AT_UMTS: u32 = 1 << 5; +const AT_HSDPA: u32 = 1 << 6; +const AT_HSUPA: u32 = 1 << 7; +const AT_HSPA: u32 = 1 << 8; +const AT_HSPA_PLUS: u32 = 1 << 9; +const AT_1XRTT: u32 = 1 << 10; +const AT_EVDO0: u32 = 1 << 11; +const AT_EVDOA: u32 = 1 << 12; +const AT_EVDOB: u32 = 1 << 13; +const AT_LTE: u32 = 1 << 14; +const AT_5GNR: u32 = 1 << 15; +const AT_LTE_CAT_M: u32 = 1 << 16; +const AT_LTE_NB_IOT: u32 = 1 << 17; + +// Convenience masks +const AT_2G: u32 = AT_GSM | AT_GSM_COMPACT | AT_GPRS | AT_EDGE; +const AT_3G: u32 = AT_UMTS | AT_HSDPA | AT_HSUPA | AT_HSPA | AT_HSPA_PLUS; +const AT_4G: u32 = AT_LTE | AT_LTE_CAT_M | AT_LTE_NB_IOT; +const AT_5G: u32 = AT_5GNR; +const AT_CDMA: u32 = AT_1XRTT | AT_EVDO0 | AT_EVDOA | AT_EVDOB; +const AT_3GPP: u32 = AT_2G | AT_3G | AT_4G | AT_5G; + +/// Bitmask of radio access technologies currently in use by a modem. +/// +/// Constructed from the raw `u32` returned by the +/// `org.freedesktop.ModemManager1.Modem.AccessTechnologies` property +/// (which combines `MM_MODEM_ACCESS_TECHNOLOGY_*` bits). +/// +/// The named bit constants are exposed as [`AccessTechnology::LTE`], +/// [`AccessTechnology::FIVE_G_NR`], etc., and predicate methods such as +/// [`AccessTechnology::has_lte`] / [`AccessTechnology::has_5g`] decode +/// common categories. +/// +/// # Example +/// +/// ```rust +/// use mmrs::AccessTechnology; +/// +/// let tech = AccessTechnology::from(0x4000); // MM_MODEM_ACCESS_TECHNOLOGY_LTE +/// assert!(tech.has_lte()); +/// assert!(tech.is_4g()); +/// assert!(tech.is_3gpp()); +/// assert!(!tech.has_5g()); +/// ``` +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct AccessTechnology(u32); + +impl AccessTechnology { + /// Empty bitmask — the modem reports no known access technology. + pub const NONE: Self = Self(0); + + /// Plain Old Telephone Service. + pub const POTS: Self = Self(AT_POTS); + /// GSM (2G). + pub const GSM: Self = Self(AT_GSM); + /// GSM Compact (2G). + pub const GSM_COMPACT: Self = Self(AT_GSM_COMPACT); + /// GPRS (2.5G). + pub const GPRS: Self = Self(AT_GPRS); + /// EDGE (2.75G). + pub const EDGE: Self = Self(AT_EDGE); + /// UMTS (3G). + pub const UMTS: Self = Self(AT_UMTS); + /// HSDPA (3.5G). + pub const HSDPA: Self = Self(AT_HSDPA); + /// HSUPA (3.5G). + pub const HSUPA: Self = Self(AT_HSUPA); + /// HSPA (3.5G). + pub const HSPA: Self = Self(AT_HSPA); + /// HSPA+ (3.75G). + pub const HSPA_PLUS: Self = Self(AT_HSPA_PLUS); + /// CDMA 1xRTT. + pub const ONE_X_RTT: Self = Self(AT_1XRTT); + /// CDMA EV-DO release 0. + pub const EVDO0: Self = Self(AT_EVDO0); + /// CDMA EV-DO revision A. + pub const EVDOA: Self = Self(AT_EVDOA); + /// CDMA EV-DO revision B. + pub const EVDOB: Self = Self(AT_EVDOB); + /// LTE (4G). + pub const LTE: Self = Self(AT_LTE); + /// 5G New Radio. + pub const FIVE_G_NR: Self = Self(AT_5GNR); + /// LTE Category M (LTE-M). + pub const LTE_CAT_M: Self = Self(AT_LTE_CAT_M); + /// LTE Narrowband IoT (NB-IoT). + pub const LTE_NB_IOT: Self = Self(AT_LTE_NB_IOT); + + /// Construct from the raw `u32` bitmask reported by ModemManager. + #[must_use] + pub const fn from_bits(bits: u32) -> Self { + Self(bits) + } + + /// Returns the raw bitmask, suitable for round-tripping over D-Bus. + #[must_use] + pub const fn bits(self) -> u32 { + self.0 + } + + /// Returns `true` when no bits are set. + #[must_use] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Returns `true` if every bit in `other` is also set in `self`. + #[must_use] + pub const fn contains(self, other: Self) -> bool { + (self.0 & other.0) == other.0 + } + + /// Returns `true` if any bit in `other` overlaps `self`. + #[must_use] + pub const fn intersects(self, other: Self) -> bool { + (self.0 & other.0) != 0 + } + + /// Returns the union of two access-technology masks. + #[must_use] + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + /// Returns `true` if any 2G technology (GSM/GPRS/EDGE) is reported. + #[must_use] + pub const fn is_2g(self) -> bool { + (self.0 & AT_2G) != 0 + } + + /// Returns `true` if any 3G technology (UMTS/HSPA family) is reported. + #[must_use] + pub const fn is_3g(self) -> bool { + (self.0 & AT_3G) != 0 + } + + /// Returns `true` if any 4G LTE variant is reported. + #[must_use] + pub const fn is_4g(self) -> bool { + (self.0 & AT_4G) != 0 + } + + /// Returns `true` if 5G NR is reported. + #[must_use] + pub const fn is_5g(self) -> bool { + (self.0 & AT_5G) != 0 + } + + /// Returns `true` if the bitmask contains an LTE variant. + /// + /// Equivalent to [`is_4g`](Self::is_4g). + #[must_use] + pub const fn has_lte(self) -> bool { + self.is_4g() + } + + /// Returns `true` if the bitmask contains 5G NR. + /// + /// Equivalent to [`is_5g`](Self::is_5g). + #[must_use] + pub const fn has_5g(self) -> bool { + self.is_5g() + } + + /// Returns `true` if the mask reports any 3GPP technology + /// (2G / 3G / 4G / 5G), as opposed to 3GPP2/CDMA. + #[must_use] + pub const fn is_3gpp(self) -> bool { + (self.0 & AT_3GPP) != 0 + } + + /// Returns `true` if the mask reports a CDMA/EV-DO technology. + #[must_use] + pub const fn is_cdma(self) -> bool { + (self.0 & AT_CDMA) != 0 + } +} + +impl From for AccessTechnology { + fn from(value: u32) -> Self { + Self(value) + } +} + +impl From for u32 { + fn from(value: AccessTechnology) -> Self { + value.0 + } +} + +impl std::ops::BitOr for AccessTechnology { + type Output = Self; + fn bitor(self, rhs: Self) -> Self { + Self(self.0 | rhs.0) + } +} + +impl std::ops::BitAnd for AccessTechnology { + type Output = Self; + fn bitand(self, rhs: Self) -> Self { + Self(self.0 & rhs.0) + } +} + +impl fmt::Display for AccessTechnology { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_empty() { + return f.write_str("none"); + } + + let mut first = true; + let mut write = |label: &str| -> fmt::Result { + if !first { + f.write_str("|")?; + } + first = false; + f.write_str(label) + }; + + let pairs: &[(u32, &str)] = &[ + (AT_POTS, "POTS"), + (AT_GSM, "GSM"), + (AT_GSM_COMPACT, "GSM-Compact"), + (AT_GPRS, "GPRS"), + (AT_EDGE, "EDGE"), + (AT_UMTS, "UMTS"), + (AT_HSDPA, "HSDPA"), + (AT_HSUPA, "HSUPA"), + (AT_HSPA, "HSPA"), + (AT_HSPA_PLUS, "HSPA+"), + (AT_1XRTT, "1xRTT"), + (AT_EVDO0, "EVDO0"), + (AT_EVDOA, "EVDOA"), + (AT_EVDOB, "EVDOB"), + (AT_LTE, "LTE"), + (AT_5GNR, "5G-NR"), + (AT_LTE_CAT_M, "LTE-M"), + (AT_LTE_NB_IOT, "NB-IoT"), + ]; + + for (bit, label) in pairs { + if self.0 & bit != 0 { + write(label)?; + } + } + + Ok(()) + } +} + +/// High-level snapshot of a managed modem. +/// +/// Mirrors the most commonly used properties on the +/// `org.freedesktop.ModemManager1.Modem` D-Bus interface. The values are +/// captured at a single point in time; use the live D-Bus proxy or the +/// monitoring API for change notifications. Construction is intentionally +/// controlled — instances are produced by the higher-level `mmrs` APIs +/// and consumed by callers via field access. +/// +/// # Example +/// +/// ```rust +/// use mmrs::Modem; +/// +/// fn signal_bar(modem: &Modem) -> &'static str { +/// match modem.signal_quality { +/// 0..=24 => "weak", +/// 25..=49 => "ok", +/// 50..=74 => "good", +/// _ => "strong", +/// } +/// } +/// +/// fn describe(modem: &Modem) -> String { +/// format!( +/// "{} {} on {} ({}%, {})", +/// modem.manufacturer, +/// modem.model, +/// modem.access_technologies, +/// modem.signal_quality, +/// modem.state, +/// ) +/// } +/// # let _ = describe; +/// # let _ = signal_bar; +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Modem { + /// D-Bus object path of the modem + /// (e.g. `/org/freedesktop/ModemManager1/Modem/0`). + pub path: String, + /// Current modem state. + pub state: ModemState, + /// Modem manufacturer (`Manufacturer` property). + pub manufacturer: String, + /// Modem model (`Model` property). + pub model: String, + /// Equipment identifier — IMEI for 3GPP modems, + /// ESN/MEID for CDMA (`EquipmentIdentifier` property). + pub equipment_identifier: String, + /// Bitmask of access technologies currently in use + /// (`AccessTechnologies` property). + pub access_technologies: AccessTechnology, + /// Signal quality as a percentage in `0..=100` + /// (`SignalQuality` property, only the first tuple member). + pub signal_quality: u32, + /// D-Bus object path of the active SIM, if any + /// (`Sim` property; `/` is reported as `None`). + pub primary_sim_path: Option, + /// D-Bus object paths of bearers owned by this modem + /// (`Bearers` property). + pub bearer_paths: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn modem_state_round_trips_through_raw() { + for raw in [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] { + let state = ModemState::from_raw(raw); + assert_eq!(state.as_raw(), raw, "round-trip broken for raw {raw}"); + } + } + + #[test] + fn modem_state_unknown_for_garbage_raw() { + assert_eq!(ModemState::from_raw(99), ModemState::Unknown); + assert_eq!(ModemState::from_raw(-42), ModemState::Unknown); + } + + #[test] + fn modem_state_predicates() { + assert!(ModemState::Connected.is_connected()); + assert!(!ModemState::Registered.is_connected()); + + assert!(ModemState::Registered.is_registered()); + assert!(ModemState::Connected.is_registered()); + assert!(!ModemState::Searching.is_registered()); + + assert!(ModemState::Searching.is_searching()); + assert!(!ModemState::Connected.is_searching()); + + assert!(ModemState::Failed.is_failed()); + assert!(!ModemState::Unknown.is_failed()); + } + + #[test] + fn modem_state_display_is_lowercase() { + assert_eq!(ModemState::Connected.to_string(), "connected"); + assert_eq!(ModemState::Disabling.to_string(), "disabling"); + } + + #[test] + fn access_technology_lte_and_5g() { + let lte = AccessTechnology::LTE; + assert!(lte.has_lte()); + assert!(lte.is_4g()); + assert!(lte.is_3gpp()); + assert!(!lte.has_5g()); + assert!(!lte.is_cdma()); + + let nr = AccessTechnology::FIVE_G_NR; + assert!(nr.has_5g()); + assert!(nr.is_5g()); + assert!(nr.is_3gpp()); + assert!(!nr.has_lte()); + } + + #[test] + fn access_technology_cdma_is_not_3gpp() { + let cdma = AccessTechnology::EVDOA; + assert!(cdma.is_cdma()); + assert!(!cdma.is_3gpp()); + } + + #[test] + fn access_technology_bit_operations() { + let lte_plus_nr = AccessTechnology::LTE | AccessTechnology::FIVE_G_NR; + assert!(lte_plus_nr.contains(AccessTechnology::LTE)); + assert!(lte_plus_nr.contains(AccessTechnology::FIVE_G_NR)); + assert!(lte_plus_nr.intersects(AccessTechnology::LTE)); + + let just_lte = lte_plus_nr & AccessTechnology::LTE; + assert_eq!(just_lte, AccessTechnology::LTE); + } + + #[test] + fn access_technology_bits_round_trip() { + let raw = AccessTechnology::LTE.bits() | AccessTechnology::HSPA.bits(); + let tech = AccessTechnology::from(raw); + assert!(tech.is_4g()); + assert!(tech.is_3g()); + assert_eq!(u32::from(tech), raw); + } + + #[test] + fn access_technology_default_and_empty() { + let empty = AccessTechnology::default(); + assert!(empty.is_empty()); + assert_eq!(empty.to_string(), "none"); + } + + #[test] + fn access_technology_display_joins_bits() { + let mixed = AccessTechnology::LTE | AccessTechnology::HSPA; + let rendered = mixed.to_string(); + assert!(rendered.contains("HSPA")); + assert!(rendered.contains("LTE")); + assert!(rendered.contains('|')); + } +} diff --git a/mmrs/src/api/models/sim.rs b/mmrs/src/api/models/sim.rs new file mode 100644 index 00000000..58775218 --- /dev/null +++ b/mmrs/src/api/models/sim.rs @@ -0,0 +1,290 @@ +//! SIM-level public types. +//! +//! Mirrors the ModemManager `org.freedesktop.ModemManager1.Sim` interface. + +use std::fmt; + +/// Snapshot of a SIM slot on a managed modem. +/// +/// Captures the most commonly used properties exposed by the +/// `org.freedesktop.ModemManager1.Sim` interface at a single point in time. +/// Construction is intentionally controlled — instances are produced by +/// the higher-level `mmrs` APIs and consumed by callers via field access. +/// +/// # Example +/// +/// ```rust +/// use mmrs::Sim; +/// +/// fn describe(sim: &Sim) -> String { +/// format!("{} (active={}, iccid={})", sim.operator_name, sim.active, sim.iccid) +/// } +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Sim { + /// D-Bus object path of the SIM + /// (e.g. `/org/freedesktop/ModemManager1/SIM/0`). + pub path: String, + /// Whether this SIM slot is currently active on the modem + /// (`Active` property). + pub active: bool, + /// Integrated Circuit Card Identifier (`SimIdentifier` property). + pub iccid: String, + /// International Mobile Subscriber Identity (`Imsi` property). + pub imsi: String, + /// Operator name reported by the SIM (`OperatorName` property). + /// + /// May be empty when the SIM does not advertise a name. + pub operator_name: String, +} + +/// SIM lock state, mapping ModemManager's `MM_MODEM_LOCK_*` constants. +/// +/// Reported via the `Modem.UnlockRequired` property; pass to +/// [`crate::ModemError::SimLocked`] when surfacing a lock to callers. +/// +/// | Raw value | Constant | Variant | +/// |-----------|-----------------------------------|-----------------| +/// | 0 | `MM_MODEM_LOCK_UNKNOWN` | `Unknown` | +/// | 1 | `MM_MODEM_LOCK_NONE` | `None` | +/// | 2 | `MM_MODEM_LOCK_SIM_PIN` | `SimPin` | +/// | 3 | `MM_MODEM_LOCK_SIM_PIN2` | `SimPin2` | +/// | 4 | `MM_MODEM_LOCK_SIM_PUK` | `SimPuk` | +/// | 5 | `MM_MODEM_LOCK_SIM_PUK2` | `SimPuk2` | +/// | 6 | `MM_MODEM_LOCK_PH_SP_PIN` | `PhoneSpPin` | +/// | 7 | `MM_MODEM_LOCK_PH_SP_PUK` | `PhoneSpPuk` | +/// | 8 | `MM_MODEM_LOCK_PH_NET_PIN` | `PhoneNetPin` | +/// | 9 | `MM_MODEM_LOCK_PH_NET_PUK` | `PhoneNetPuk` | +/// | 10 | `MM_MODEM_LOCK_PH_SIM_PIN` | `PhoneSimPin` | +/// | 11 | `MM_MODEM_LOCK_PH_CORP_PIN` | `PhoneCorpPin` | +/// | 12 | `MM_MODEM_LOCK_PH_CORP_PUK` | `PhoneCorpPuk` | +/// | 13 | `MM_MODEM_LOCK_PH_FSIM_PIN` | `PhoneFsimPin` | +/// | 14 | `MM_MODEM_LOCK_PH_FSIM_PUK` | `PhoneFsimPuk` | +/// | 15 | `MM_MODEM_LOCK_PH_NETSUB_PIN` | `PhoneNetSubPin`| +/// | 16 | `MM_MODEM_LOCK_PH_NETSUB_PUK` | `PhoneNetSubPuk`| +/// +/// # Example +/// +/// ```rust +/// use mmrs::SimLockState; +/// +/// assert_eq!(SimLockState::from_raw(2), SimLockState::SimPin); +/// assert!(SimLockState::SimPin.requires_pin()); +/// assert!(SimLockState::SimPuk.requires_puk()); +/// assert!(!SimLockState::None.is_locked()); +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SimLockState { + /// Lock state is not yet known. + Unknown, + /// No lock is active. + None, + /// SIM PIN required. + SimPin, + /// SIM PIN2 required. + SimPin2, + /// SIM PUK required (PIN retries exhausted). + SimPuk, + /// SIM PUK2 required. + SimPuk2, + /// Service-provider personalization PIN required. + PhoneSpPin, + /// Service-provider personalization PUK required. + PhoneSpPuk, + /// Network personalization PIN required. + PhoneNetPin, + /// Network personalization PUK required. + PhoneNetPuk, + /// Phone-to-SIM PIN required. + PhoneSimPin, + /// Corporate personalization PIN required. + PhoneCorpPin, + /// Corporate personalization PUK required. + PhoneCorpPuk, + /// Phone-to-very-first-SIM PIN required. + PhoneFsimPin, + /// Phone-to-very-first-SIM PUK required. + PhoneFsimPuk, + /// Network subset personalization PIN required. + PhoneNetSubPin, + /// Network subset personalization PUK required. + PhoneNetSubPuk, +} + +impl SimLockState { + /// Decode the raw `u32` value from `Modem.UnlockRequired`. + /// + /// Unrecognised values map to [`SimLockState::Unknown`]. + #[must_use] + pub const fn from_raw(value: u32) -> Self { + match value { + 1 => Self::None, + 2 => Self::SimPin, + 3 => Self::SimPin2, + 4 => Self::SimPuk, + 5 => Self::SimPuk2, + 6 => Self::PhoneSpPin, + 7 => Self::PhoneSpPuk, + 8 => Self::PhoneNetPin, + 9 => Self::PhoneNetPuk, + 10 => Self::PhoneSimPin, + 11 => Self::PhoneCorpPin, + 12 => Self::PhoneCorpPuk, + 13 => Self::PhoneFsimPin, + 14 => Self::PhoneFsimPuk, + 15 => Self::PhoneNetSubPin, + 16 => Self::PhoneNetSubPuk, + _ => Self::Unknown, + } + } + + /// Returns the raw `MM_MODEM_LOCK_*` constant. + #[must_use] + pub const fn as_raw(self) -> u32 { + match self { + Self::Unknown => 0, + Self::None => 1, + Self::SimPin => 2, + Self::SimPin2 => 3, + Self::SimPuk => 4, + Self::SimPuk2 => 5, + Self::PhoneSpPin => 6, + Self::PhoneSpPuk => 7, + Self::PhoneNetPin => 8, + Self::PhoneNetPuk => 9, + Self::PhoneSimPin => 10, + Self::PhoneCorpPin => 11, + Self::PhoneCorpPuk => 12, + Self::PhoneFsimPin => 13, + Self::PhoneFsimPuk => 14, + Self::PhoneNetSubPin => 15, + Self::PhoneNetSubPuk => 16, + } + } + + /// Returns `true` if the SIM is in any locked state (i.e. not + /// [`None`](Self::None)). + /// + /// [`Unknown`](Self::Unknown) is treated as locked because the modem + /// has not yet confirmed an unlocked state. + #[must_use] + pub const fn is_locked(self) -> bool { + !matches!(self, Self::None) + } + + /// Returns `true` when a PIN code is required to unlock. + #[must_use] + pub const fn requires_pin(self) -> bool { + matches!( + self, + Self::SimPin + | Self::SimPin2 + | Self::PhoneSpPin + | Self::PhoneNetPin + | Self::PhoneSimPin + | Self::PhoneCorpPin + | Self::PhoneFsimPin + | Self::PhoneNetSubPin + ) + } + + /// Returns `true` when a PUK code is required (PIN retries exhausted). + #[must_use] + pub const fn requires_puk(self) -> bool { + matches!( + self, + Self::SimPuk + | Self::SimPuk2 + | Self::PhoneSpPuk + | Self::PhoneNetPuk + | Self::PhoneCorpPuk + | Self::PhoneFsimPuk + | Self::PhoneNetSubPuk + ) + } +} + +impl From for SimLockState { + fn from(value: u32) -> Self { + Self::from_raw(value) + } +} + +impl fmt::Display for SimLockState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::Unknown => "unknown", + Self::None => "none", + Self::SimPin => "sim-pin", + Self::SimPin2 => "sim-pin2", + Self::SimPuk => "sim-puk", + Self::SimPuk2 => "sim-puk2", + Self::PhoneSpPin => "ph-sp-pin", + Self::PhoneSpPuk => "ph-sp-puk", + Self::PhoneNetPin => "ph-net-pin", + Self::PhoneNetPuk => "ph-net-puk", + Self::PhoneSimPin => "ph-sim-pin", + Self::PhoneCorpPin => "ph-corp-pin", + Self::PhoneCorpPuk => "ph-corp-puk", + Self::PhoneFsimPin => "ph-fsim-pin", + Self::PhoneFsimPuk => "ph-fsim-puk", + Self::PhoneNetSubPin => "ph-netsub-pin", + Self::PhoneNetSubPuk => "ph-netsub-puk", + }; + f.write_str(label) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lock_state_round_trip() { + for raw in 0u32..=16 { + let state = SimLockState::from_raw(raw); + assert_eq!(state.as_raw(), raw, "round-trip broken for raw {raw}"); + } + } + + #[test] + fn lock_state_unknown_for_garbage_raw() { + assert_eq!(SimLockState::from_raw(999), SimLockState::Unknown); + } + + #[test] + fn lock_state_predicates() { + assert!(!SimLockState::None.is_locked()); + assert!(SimLockState::SimPin.is_locked()); + assert!(SimLockState::Unknown.is_locked()); + + assert!(SimLockState::SimPin.requires_pin()); + assert!(SimLockState::PhoneSimPin.requires_pin()); + assert!(!SimLockState::SimPuk.requires_pin()); + + assert!(SimLockState::SimPuk.requires_puk()); + assert!(SimLockState::PhoneCorpPuk.requires_puk()); + assert!(!SimLockState::SimPin.requires_puk()); + } + + #[test] + fn lock_state_display() { + assert_eq!(SimLockState::SimPin.to_string(), "sim-pin"); + assert_eq!(SimLockState::PhoneFsimPuk.to_string(), "ph-fsim-puk"); + } + + #[test] + fn sim_struct_construction() { + let sim = Sim { + path: "/org/freedesktop/ModemManager1/SIM/0".into(), + active: true, + iccid: "89014103211118510720".into(), + imsi: "310410000000000".into(), + operator_name: "Test Carrier".into(), + }; + assert!(sim.active); + assert_eq!(sim.operator_name, "Test Carrier"); + } +} diff --git a/mmrs/src/lib.rs b/mmrs/src/lib.rs index bdcdf78e..5049134a 100644 --- a/mmrs/src/lib.rs +++ b/mmrs/src/lib.rs @@ -1,5 +1,58 @@ +//! 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. +//! +//! # Modules +//! +//! - [`models`] re-exports every public data type under [`crate::api::models`]. +//! The same types are re-exported at the crate root, so `mmrs::ModemState`, +//! `mmrs::models::ModemState`, and `mmrs::api::models::ModemState` refer to +//! the same item. +//! +//! # Quick reference +//! +//! - **Modem** — [`Modem`], [`ModemState`], [`AccessTechnology`] +//! - **SIM** — [`Sim`], [`SimLockState`] +//! - **Bearer** — [`Bearer`], [`BearerConfig`], [`BearerStats`], +//! [`Ip4Config`], [`IpType`] +//! - **Errors** — [`ModemError`], [`Result`] +//! +//! # Example +//! +//! ```rust +//! use mmrs::{AccessTechnology, BearerConfig, IpType, ModemState}; +//! +//! let state = ModemState::from_raw(11); +//! assert!(state.is_connected()); +//! +//! let tech = AccessTechnology::from(0x4000); // MM_MODEM_ACCESS_TECHNOLOGY_LTE +//! assert!(tech.has_lte()); +//! +//! let cfg = BearerConfig::new("internet") +//! .with_ip_type(IpType::Ipv4v6) +//! .with_user("user") +//! .with_password("hunter2"); +//! assert_eq!(cfg.apn, "internet"); +//! ``` + pub mod api; pub mod core; pub mod dbus; pub mod monitoring; pub mod types; + +/// Public data types for ModemManager (modems, SIMs, bearers, errors). +/// +/// Every item in this module is also re-exported at the crate root. +pub mod models { + pub use crate::api::models::*; +} + +pub use api::models::{ + AccessTechnology, Bearer, BearerConfig, BearerStats, Ip4Config, IpType, Modem, ModemError, + ModemState, Result, Sim, SimLockState, +};