From ceaf2239bc4a248ad4c590633cf96142aea77ae8 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Fri, 17 Jul 2026 23:20:37 -0400 Subject: [PATCH 01/10] ci: add NetworkManager integration test harness --- .github/workflows/ci.yml | 10 ++++ CONTRIBUTING.md | 5 +- docker-compose.yml | 43 ++++++---------- docs/src/development/contributing.md | 5 +- docs/src/development/testing.md | 10 ++-- nmrs/tests/integration_test.rs | 5 +- scripts/ci/dbus-system.conf | 15 ++++++ scripts/ci/run-networkmanager-tests.sh | 69 ++++++++++++++++++++++++++ 8 files changed, 128 insertions(+), 34 deletions(-) create mode 100644 scripts/ci/dbus-system.conf create mode 100755 scripts/ci/run-networkmanager-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2723184f..62484faa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,16 @@ jobs: - name: Run doc tests run: cargo test --doc --all-features --workspace + integration: + name: NetworkManager Integration + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - name: Run integration tests with NetworkManager + run: docker compose run --build --rm test-integration + semver: name: Semantic Versioning Check runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8370abe..9aa699e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,9 +25,12 @@ docker build -t nmrs-lib . **To run tests:** ```bash -docker compose run test +docker compose run --rm test ``` +This starts an isolated system D-Bus and NetworkManager instance, waits for it +to be ready, and requires integration tests to connect to it. + **To run an interactive shell:** ```bash diff --git a/docker-compose.yml b/docker-compose.yml index cfec3e7d..59b85b83 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,15 +10,7 @@ services: - target-cache:/app/target environment: - RUST_BACKTRACE=1 - command: > - bash -c " - mkdir -p /run/dbus && - dbus-daemon --system --fork && - sleep 1 && - NetworkManager --no-daemon & - sleep 3 && - cargo test -p nmrs - " + command: bash scripts/ci/run-networkmanager-tests.sh all test-all: build: @@ -31,15 +23,20 @@ services: - target-cache:/app/target environment: - RUST_BACKTRACE=1 - command: > - bash -c " - mkdir -p /run/dbus && - dbus-daemon --system --fork && - sleep 1 && - NetworkManager --no-daemon & - sleep 3 && - cargo test --workspace - " + command: bash scripts/ci/run-networkmanager-tests.sh all + + test-integration: + build: + context: . + dockerfile: Dockerfile + privileged: true + volumes: + - .:/app + - cargo-cache:/root/.cargo/registry + - target-cache:/app/target + environment: + - RUST_BACKTRACE=1 + command: bash scripts/ci/run-networkmanager-tests.sh integration shell: build: @@ -52,15 +49,7 @@ services: - target-cache:/app/target environment: - RUST_BACKTRACE=1 - command: > - bash -c " - mkdir -p /run/dbus && - dbus-daemon --system --fork && - sleep 1 && - NetworkManager --no-daemon & - sleep 3 && - /bin/bash - " + command: bash scripts/ci/run-networkmanager-tests.sh shell stdin_open: true tty: true diff --git a/docs/src/development/contributing.md b/docs/src/development/contributing.md index 3f14b3df..a0ae7af2 100644 --- a/docs/src/development/contributing.md +++ b/docs/src/development/contributing.md @@ -18,9 +18,12 @@ I also provide a `Dockerfile` you can build if you don't use Linux and use MacOS ### To run tests: ```bash -docker compose run test +docker compose run --rm test ``` +This starts an isolated system D-Bus and NetworkManager instance before running +the test suite. + ### To run an interactive shell: ```bash docker compose run shell diff --git a/docs/src/development/testing.md b/docs/src/development/testing.md index 4430ea79..a1747d1f 100644 --- a/docs/src/development/testing.md +++ b/docs/src/development/testing.md @@ -108,11 +108,12 @@ mod tests { For reproducible testing with a real NetworkManager instance: ```bash -docker build -t nmrs-test . -docker run --privileged nmrs-test cargo test +docker compose run --build --rm test-integration ``` -The project includes a Dockerfile configured for testing. +This starts a private system D-Bus and NetworkManager instance, waits for it to +be ready, and fails if tests cannot connect to the daemon. Wi-Fi-specific tests +continue to skip until the test environment has a Wi-Fi device. ## CI/CD @@ -121,7 +122,8 @@ Tests run automatically via GitHub Actions on every push and pull request. The C 1. Checks formatting (`cargo fmt --check`) 2. Runs clippy (`cargo clippy`) 3. Runs unit tests (`cargo test`) -4. Builds documentation (`mdbook build`) +4. Runs integration tests against NetworkManager in Docker +5. Builds documentation (`mdbook build`) ## Next Steps diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 044ae1c0..fe67924f 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -28,10 +28,13 @@ async fn has_ethernet_device(nm: &NetworkManager) -> bool { .unwrap_or(false) } -/// Skip tests if NetworkManager is not available +/// Skip tests if NetworkManager is not available outside the integration harness. macro_rules! require_networkmanager { () => { if !is_networkmanager_available().await { + if std::env::var_os("NMRS_REQUIRE_NETWORKMANAGER").is_some() { + panic!("NetworkManager is required but unavailable"); + } eprintln!("Skipping test: NetworkManager not available"); return; } diff --git a/scripts/ci/dbus-system.conf b/scripts/ci/dbus-system.conf new file mode 100644 index 00000000..cd6b1793 --- /dev/null +++ b/scripts/ci/dbus-system.conf @@ -0,0 +1,15 @@ + + + + system + unix:path=/run/dbus/system_bus_socket + EXTERNAL + + + + + + + + diff --git a/scripts/ci/run-networkmanager-tests.sh b/scripts/ci/run-networkmanager-tests.sh new file mode 100755 index 00000000..46f09b2c --- /dev/null +++ b/scripts/ci/run-networkmanager-tests.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly project_root="$(cd "${script_dir}/../.." && pwd)" +readonly networkmanager_log="${TMPDIR:-/tmp}/nmrs-networkmanager.log" +networkmanager_pid="" + +cleanup() { + if [[ -n "${networkmanager_pid}" ]] && kill -0 "${networkmanager_pid}" 2>/dev/null; then + kill "${networkmanager_pid}" || true + wait "${networkmanager_pid}" || true + fi +} + +print_networkmanager_log() { + echo "NetworkManager did not become ready. Its log follows:" >&2 + cat "${networkmanager_log}" >&2 || true +} + +trap cleanup EXIT + +mkdir -p /run/dbus +dbus-daemon \ + --config-file="${project_root}/scripts/ci/dbus-system.conf" \ + --fork \ + --nopidfile + +NetworkManager --no-daemon --log-level=INFO >"${networkmanager_log}" 2>&1 & +networkmanager_pid=$! + +for _ in $(seq 1 30); do + if nmcli --terse --fields RUNNING general 2>/dev/null | grep --quiet '^running$'; then + break + fi + + if ! kill -0 "${networkmanager_pid}" 2>/dev/null; then + print_networkmanager_log + exit 1 + fi + + sleep 1 +done + +if ! nmcli --terse --fields RUNNING general 2>/dev/null | grep --quiet '^running$'; then + print_networkmanager_log + exit 1 +fi + +nmcli general status + +export NMRS_REQUIRE_NETWORKMANAGER=1 + +case "${1:-all}" in + all) + cargo test --all-features --workspace + ;; + integration) + cargo test --test integration_test --all-features + ;; + shell) + bash + ;; + *) + echo "Usage: $0 [all|integration|shell]" >&2 + exit 2 + ;; +esac From bdb7c9ebbfca2846e0ef3b3b535848c00a2a55c3 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Fri, 17 Jul 2026 23:26:32 -0400 Subject: [PATCH 02/10] build: track Cargo.lock for reproducible CI builds --- .github/workflows/ci.yml | 34 +- .gitignore | 1 - Cargo.lock | 1077 ++++++++++++++++++++++++ Dockerfile | 2 +- MODEM_MANAGER_PLAN.md | 1077 ------------------------ scripts/ci/run-networkmanager-tests.sh | 4 +- 6 files changed, 1092 insertions(+), 1103 deletions(-) create mode 100644 Cargo.lock delete mode 100644 MODEM_MANAGER_PLAN.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62484faa..27f9e4f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: restore-keys: | ${{ runner.os }}-clippy- - - run: cargo clippy --all-targets --all-features -- -D warnings + - run: cargo clippy --locked --all-targets --all-features -- -D warnings test: name: Unit Tests @@ -87,11 +87,14 @@ jobs: restore-keys: | ${{ runner.os }}-test- + - name: Build release artifact + run: cargo build --locked --release --package nmrs + - name: Run unit tests - run: cargo test --lib --all-features --workspace + run: cargo test --locked --lib --all-features --workspace - name: Run doc tests - run: cargo test --doc --all-features --workspace + run: cargo test --locked --doc --all-features --workspace integration: name: NetworkManager Integration @@ -128,16 +131,8 @@ jobs: run: cargo semver-checks check-release -p nmrs build: - name: Build (${{ matrix.target }}) + name: Build (aarch64-unknown-linux-gnu) runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - target: x86_64-unknown-linux-gnu - packages: nmrs - - target: aarch64-unknown-linux-gnu - packages: nmrs steps: - uses: actions/checkout@v7 @@ -149,12 +144,11 @@ jobs: libdbus-1-dev - name: Install cross-compilation tools - if: matrix.target == 'aarch64-unknown-linux-gnu' run: sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu - uses: dtolnay/rust-toolchain@stable with: - targets: ${{ matrix.target }} + targets: aarch64-unknown-linux-gnu - uses: actions/cache@v6 with: @@ -162,16 +156,12 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-build-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-build-aarch64-unknown-linux-gnu-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-build-${{ matrix.target }}- + ${{ runner.os }}-build-aarch64-unknown-linux-gnu- - - name: Build ${{ matrix.packages }} - run: | - for package in ${{ matrix.packages }}; do - echo "Building $package for ${{ matrix.target }}" - cargo build --release --target ${{ matrix.target }} --package $package - done + - name: Build nmrs + run: cargo build --locked --release --target aarch64-unknown-linux-gnu --package nmrs env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc PKG_CONFIG_ALLOW_CROSS: 1 diff --git a/.gitignore b/.gitignore index a09551bd..f35f2eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,3 @@ vendor/ /pkg/ /src/ *.pkg.tar.zst -Cargo.lock \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..76b98df6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1077 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nmrs" +version = "3.4.0" +dependencies = [ + "async-trait", + "base64", + "bitflags", + "futures", + "futures-timer", + "log", + "serde", + "serial_test", + "thiserror", + "tokio", + "uuid", + "zbus", + "zvariant", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Dockerfile b/Dockerfile index 994dbce6..4b6dc5cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,6 @@ RUN apt-get update && apt-get install -y \ COPY Cargo.toml Cargo.lock ./ COPY nmrs ./nmrs -RUN cargo build -p nmrs --release && cargo build -p nmrs +RUN cargo build --locked -p nmrs --release && cargo build --locked -p nmrs CMD ["/bin/bash"] diff --git a/MODEM_MANAGER_PLAN.md b/MODEM_MANAGER_PLAN.md deleted file mode 100644 index d312ccb5..00000000 --- a/MODEM_MANAGER_PLAN.md +++ /dev/null @@ -1,1077 +0,0 @@ -# ModemManager Support Plan for nmrs Workspace - -This document outlines a practical plan for adding ModemManager (MM) D-Bus bindings as a sibling crate to `nmrs`. The goal is to provide a high-level async Rust API for cellular modem control, mirroring the patterns already established in `nmrs`. - ---- - -## 1. Crate Structure - -### Recommended: New `mmrs` Crate in Workspace - -``` -nmrs/ # workspace root -├── Cargo.toml # workspace manifest (already exists) -├── nmrs/ # existing NetworkManager bindings -│ └── ... -└── mmrs/ # NEW: ModemManager bindings - ├── Cargo.toml - ├── README.md - ├── CHANGELOG.md - └── src/ - ├── lib.rs - ├── api/ - │ ├── mod.rs - │ ├── modem_manager.rs # Main entry point (like network_manager.rs) - │ ├── modem_scope.rs # Per-modem scoped API (like wifi_scope.rs) - │ ├── models/ - │ │ ├── mod.rs - │ │ ├── modem.rs # Modem, ModemState, AccessTechnology - │ │ ├── sim.rs # Sim, SimState, PinState - │ │ ├── bearer.rs # Bearer, BearerProperties, BearerStats - │ │ ├── signal.rs # SignalQuality, CellInfo - │ │ ├── location.rs # LocationInfo, GpsData - │ │ ├── sms.rs # Sms, SmsState - │ │ └── error.rs # ModemError enum - │ └── builders/ - │ ├── mod.rs - │ ├── bearer.rs # BearerConfig builder - │ └── sms.rs # SmsConfig builder - ├── core/ - │ ├── mod.rs - │ ├── modem.rs # Modem operations - │ ├── sim.rs # SIM/PIN operations - │ ├── bearer.rs # Bearer connect/disconnect - │ ├── signal.rs # Signal quality monitoring - │ ├── location.rs # GPS/location services - │ └── sms.rs # SMS send/receive - ├── dbus/ - │ ├── mod.rs - │ ├── manager.rs # org.freedesktop.ModemManager1 - │ ├── modem.rs # org.freedesktop.ModemManager1.Modem - │ ├── modem_simple.rs # org.freedesktop.ModemManager1.Modem.Simple - │ ├── modem_3gpp.rs # org.freedesktop.ModemManager1.Modem.Modem3gpp - │ ├── modem_cdma.rs # org.freedesktop.ModemManager1.Modem.ModemCdma - │ ├── sim.rs # org.freedesktop.ModemManager1.Sim - │ ├── bearer.rs # org.freedesktop.ModemManager1.Bearer - │ ├── sms.rs # org.freedesktop.ModemManager1.Sms - │ ├── location.rs # org.freedesktop.ModemManager1.Modem.Location - │ └── signal.rs # org.freedesktop.ModemManager1.Modem.Signal - ├── monitoring/ - │ ├── mod.rs - │ ├── modem.rs # Modem state change streams - │ ├── signal.rs # Signal quality streams - │ └── sms.rs # Incoming SMS streams - └── types/ - ├── mod.rs - └── constants.rs # MM D-Bus constants (states, access tech, etc.) -``` - -### Workspace Cargo.toml Changes - -```toml -# Root Cargo.toml -[workspace] -members = [ - "nmrs", - "mmrs", -] -resolver = "3" - -[workspace.dependencies] -# Shared dependencies - both crates use identical versions -zbus = "5.15.0" -zvariant = "5.11.0" -log = "0.4.29" -serde = { version = "1.0.228", features = ["derive"] } -thiserror = "2.0.18" -futures = "0.3.32" -tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time"] } -async-trait = "0.1.89" -bitflags = "2.11.1" - -# Workspace crates can depend on each other -nmrs = { path = "nmrs", version = "3.1" } -mmrs = { path = "mmrs", version = "0.1" } -``` - ---- - -## 2. D-Bus Proxy Layer (Phase 1) - -The foundation. Mirror the `zbus::proxy` macro pattern from `nmrs/src/dbus/`. - -### 2.1 Manager Proxy - -```rust -// mmrs/src/dbus/manager.rs -use zbus::proxy; -use zvariant::OwnedObjectPath; - -#[proxy( - interface = "org.freedesktop.ModemManager1", - default_service = "org.freedesktop.ModemManager1", - default_path = "/org/freedesktop/ModemManager1" -)] -pub trait MM { - /// Force MM to re-scan for devices. - fn scan_devices(&self) -> zbus::Result<()>; - - /// Set logging verbosity ("ERR", "WARN", "INFO", "DEBUG"). - fn set_logging(&self, level: &str) -> zbus::Result<()>; - - /// Report a kernel event (udev passthrough). - fn report_kernel_event(&self, properties: std::collections::HashMap<&str, zvariant::Value<'_>>) -> zbus::Result<()>; - - /// Inhibit modem device handling (returns inhibition cookie). - fn inhibit_device(&self, uid: &str) -> zbus::Result; - - /// Uninhibit a previously inhibited device. - fn uninhibit_device(&self, uid: &str) -> zbus::Result<()>; - - /// MM daemon version string. - #[zbus(property)] - fn version(&self) -> zbus::Result; -} -``` - -### 2.2 Modem Proxy - -```rust -// mmrs/src/dbus/modem.rs -use zbus::proxy; -use zvariant::OwnedObjectPath; - -#[proxy( - interface = "org.freedesktop.ModemManager1.Modem", - default_service = "org.freedesktop.ModemManager1" -)] -pub trait MMModem { - /// Enable or disable the modem. - fn enable(&self, enable: bool) -> zbus::Result<()>; - - /// List bearer object paths. - fn list_bearers(&self) -> zbus::Result>; - - /// Create a new bearer with given properties. - fn create_bearer( - &self, - properties: std::collections::HashMap<&str, zvariant::Value<'_>>, - ) -> zbus::Result; - - /// Delete a bearer. - fn delete_bearer(&self, bearer: OwnedObjectPath) -> zbus::Result<()>; - - /// Reset the modem. - fn reset(&self) -> zbus::Result<()>; - - /// Factory reset (requires unlock code). - fn factory_reset(&self, code: &str) -> zbus::Result<()>; - - /// Set power state (low, on, off). - fn set_power_state(&self, state: u32) -> zbus::Result<()>; - - /// Set allowed/preferred network modes. - fn set_current_modes(&self, modes: (u32, u32)) -> zbus::Result<()>; - - /// Set current bands. - fn set_current_bands(&self, bands: Vec) -> zbus::Result<()>; - - /// Request modem command (AT command passthrough). - fn command(&self, cmd: &str, timeout: u32) -> zbus::Result; - - // --- Properties --- - - #[zbus(property)] - fn sim(&self) -> zbus::Result; - - #[zbus(property)] - fn sim_slots(&self) -> zbus::Result>; - - #[zbus(property)] - fn primary_sim_slot(&self) -> zbus::Result; - - #[zbus(property)] - fn bearers(&self) -> zbus::Result>; - - #[zbus(property)] - fn state(&self) -> zbus::Result; - - #[zbus(property)] - fn state_failed_reason(&self) -> zbus::Result; - - #[zbus(property)] - fn power_state(&self) -> zbus::Result; - - #[zbus(property)] - fn access_technologies(&self) -> zbus::Result; - - #[zbus(property)] - fn signal_quality(&self) -> zbus::Result<(u32, bool)>; - - #[zbus(property)] - fn own_numbers(&self) -> zbus::Result>; - - #[zbus(property)] - fn manufacturer(&self) -> zbus::Result; - - #[zbus(property)] - fn model(&self) -> zbus::Result; - - #[zbus(property)] - fn revision(&self) -> zbus::Result; - - #[zbus(property)] - fn equipment_identifier(&self) -> zbus::Result; - - #[zbus(property)] - fn device_identifier(&self) -> zbus::Result; - - #[zbus(property)] - fn device(&self) -> zbus::Result; - - #[zbus(property)] - fn drivers(&self) -> zbus::Result>; - - #[zbus(property)] - fn plugin(&self) -> zbus::Result; - - #[zbus(property)] - fn primary_port(&self) -> zbus::Result; - - #[zbus(property)] - fn ports(&self) -> zbus::Result>; - - #[zbus(property)] - fn current_modes(&self) -> zbus::Result<(u32, u32)>; - - #[zbus(property)] - fn supported_modes(&self) -> zbus::Result>; - - #[zbus(property)] - fn current_bands(&self) -> zbus::Result>; - - #[zbus(property)] - fn supported_bands(&self) -> zbus::Result>; - - #[zbus(property)] - fn max_bearers(&self) -> zbus::Result; - - #[zbus(property)] - fn max_active_bearers(&self) -> zbus::Result; - - // --- Signals --- - - #[zbus(signal)] - fn state_changed(&self, old: i32, new: i32, reason: u32); -} -``` - -### 2.3 Simple Modem Proxy - -```rust -// mmrs/src/dbus/modem_simple.rs -use zbus::proxy; -use std::collections::HashMap; -use zvariant::{OwnedObjectPath, OwnedValue}; - -#[proxy( - interface = "org.freedesktop.ModemManager1.Modem.Simple", - default_service = "org.freedesktop.ModemManager1" -)] -pub trait MMModemSimple { - /// One-shot connect: enable modem, register, create bearer, connect. - fn connect( - &self, - properties: HashMap<&str, zvariant::Value<'_>>, - ) -> zbus::Result; - - /// Disconnect all or a specific bearer. - fn disconnect(&self, bearer: OwnedObjectPath) -> zbus::Result<()>; - - /// Get current modem status (registration, signal, bearer state, etc.). - fn get_status(&self) -> zbus::Result>; -} -``` - -### 2.4 SIM Proxy - -```rust -// mmrs/src/dbus/sim.rs -use zbus::proxy; - -#[proxy( - interface = "org.freedesktop.ModemManager1.Sim", - default_service = "org.freedesktop.ModemManager1" -)] -pub trait MMSim { - /// Send PIN to unlock SIM. - fn send_pin(&self, pin: &str) -> zbus::Result<()>; - - /// Send PUK and new PIN. - fn send_puk(&self, puk: &str, pin: &str) -> zbus::Result<()>; - - /// Enable or disable PIN requirement. - fn enable_pin(&self, pin: &str, enabled: bool) -> zbus::Result<()>; - - /// Change the SIM PIN. - fn change_pin(&self, old_pin: &str, new_pin: &str) -> zbus::Result<()>; - - /// Set preferred networks (for roaming). - fn set_preferred_networks( - &self, - preferred: Vec<(String, u32)>, - ) -> zbus::Result<()>; - - // --- Properties --- - - #[zbus(property)] - fn active(&self) -> zbus::Result; - - #[zbus(property)] - fn sim_identifier(&self) -> zbus::Result; - - #[zbus(property)] - fn imsi(&self) -> zbus::Result; - - #[zbus(property)] - fn eid(&self) -> zbus::Result; - - #[zbus(property)] - fn operator_identifier(&self) -> zbus::Result; - - #[zbus(property)] - fn operator_name(&self) -> zbus::Result; - - #[zbus(property)] - fn emergency_numbers(&self) -> zbus::Result>; - - #[zbus(property)] - fn preferred_networks(&self) -> zbus::Result>; -} -``` - -### 2.5 Bearer Proxy - -```rust -// mmrs/src/dbus/bearer.rs -use zbus::proxy; -use std::collections::HashMap; -use zvariant::OwnedValue; - -#[proxy( - interface = "org.freedesktop.ModemManager1.Bearer", - default_service = "org.freedesktop.ModemManager1" -)] -pub trait MMBearer { - /// Connect the bearer (bring up data session). - fn connect(&self) -> zbus::Result<()>; - - /// Disconnect the bearer. - fn disconnect(&self) -> zbus::Result<()>; - - // --- Properties --- - - #[zbus(property)] - fn interface(&self) -> zbus::Result; - - #[zbus(property)] - fn connected(&self) -> zbus::Result; - - #[zbus(property)] - fn suspended(&self) -> zbus::Result; - - #[zbus(property)] - fn multiplexed(&self) -> zbus::Result; - - #[zbus(property)] - fn ip4_config(&self) -> zbus::Result>; - - #[zbus(property)] - fn ip6_config(&self) -> zbus::Result>; - - #[zbus(property)] - fn stats(&self) -> zbus::Result>; - - #[zbus(property)] - fn ip_timeout(&self) -> zbus::Result; - - #[zbus(property)] - fn bearer_type(&self) -> zbus::Result; - - #[zbus(property)] - fn properties(&self) -> zbus::Result>; -} -``` - ---- - -## 3. Model Layer (Phase 2) - -Type-safe Rust structs wrapping raw D-Bus values. - -### 3.1 Core Enums and Types - -```rust -// mmrs/src/api/models/modem.rs - -/// Modem state (from MM_MODEM_STATE_*). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum ModemState { - Failed, - Unknown, - Initializing, - Locked, - Disabled, - Disabling, - Enabling, - Enabled, - Searching, - Registered, - Disconnecting, - Connecting, - Connected, -} - -/// Access technology flags (bitmask). -#[derive(Debug, Clone, Copy)] -#[non_exhaustive] -pub struct AccessTechnology(u32); - -impl AccessTechnology { - pub const UNKNOWN: u32 = 0; - pub const POTS: u32 = 1 << 0; - pub const GSM: u32 = 1 << 1; - pub const GSM_COMPACT: u32 = 1 << 2; - pub const GPRS: u32 = 1 << 3; - pub const EDGE: u32 = 1 << 4; - pub const UMTS: u32 = 1 << 5; - pub const HSDPA: u32 = 1 << 6; - pub const HSUPA: u32 = 1 << 7; - pub const HSPA: u32 = 1 << 8; - pub const HSPA_PLUS: u32 = 1 << 9; - pub const EVDO0: u32 = 1 << 10; - pub const EVDOA: u32 = 1 << 11; - pub const EVDOB: u32 = 1 << 12; - pub const LTE: u32 = 1 << 14; - pub const FIVE_GNR: u32 = 1 << 15; - pub const LTE_CAT_M: u32 = 1 << 16; - pub const LTE_NB_IOT: u32 = 1 << 17; - - pub fn has_lte(&self) -> bool { self.0 & Self::LTE != 0 } - pub fn has_5g(&self) -> bool { self.0 & Self::FIVE_GNR != 0 } - pub fn is_3gpp(&self) -> bool { - (self.0 & (Self::GSM | Self::GPRS | Self::EDGE | Self::UMTS | Self::HSDPA | - Self::HSUPA | Self::HSPA | Self::HSPA_PLUS | Self::LTE | Self::FIVE_GNR)) != 0 - } -} - -/// Modem information snapshot. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct Modem { - pub path: String, - pub state: ModemState, - pub manufacturer: String, - pub model: String, - pub revision: String, - pub equipment_identifier: String, // IMEI - pub device: String, // sysfs path - pub primary_port: String, - pub access_technologies: AccessTechnology, - pub signal_quality: u32, - pub signal_quality_recent: bool, - pub own_numbers: Vec, - pub max_bearers: u32, - pub max_active_bearers: u32, -} -``` - -### 3.2 SIM Types - -```rust -// mmrs/src/api/models/sim.rs - -/// SIM lock state. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum SimLockState { - Unknown, - Unlocked, - PinRequired, - PukRequired, - PhNetPinRequired, - // ... other lock states -} - -/// SIM card information. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct Sim { - pub path: String, - pub active: bool, - pub iccid: String, // sim_identifier - pub imsi: String, - pub eid: Option, // eSIM identifier - pub operator_id: String, - pub operator_name: String, - pub emergency_numbers: Vec, -} -``` - -### 3.3 Bearer Types - -```rust -// mmrs/src/api/models/bearer.rs - -/// Bearer (data session) state. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct Bearer { - pub path: String, - pub interface: String, // e.g., "wwan0" - pub connected: bool, - pub suspended: bool, - pub ip4_config: Option, - pub ip6_config: Option, - pub stats: BearerStats, -} - -/// IPv4 configuration from bearer. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct Ip4Config { - pub address: String, - pub prefix: u32, - pub gateway: Option, - pub dns: Vec, - pub mtu: Option, -} - -/// Bearer statistics. -#[derive(Debug, Clone, Default)] -#[non_exhaustive] -pub struct BearerStats { - pub rx_bytes: u64, - pub tx_bytes: u64, - pub duration: u32, - pub attempts: u32, - pub failed_attempts: u32, -} - -/// Bearer configuration for creating new bearers. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct BearerConfig { - pub apn: String, - pub ip_type: IpType, - pub user: Option, - pub password: Option, - pub auth_method: Option, - pub allow_roaming: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum IpType { - #[default] - Ipv4, - Ipv6, - Ipv4v6, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthMethod { - None, - Pap, - Chap, - MschapV2, -} -``` - -### 3.4 Error Types - -```rust -// mmrs/src/api/models/error.rs - -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum ModemError { - #[error("D-Bus error: {0}")] - Dbus(#[from] zbus::Error), - - #[error("no modems found")] - NoModems, - - #[error("modem not found: {0}")] - ModemNotFound(String), - - #[error("SIM not inserted")] - NoSim, - - #[error("SIM locked: {0:?}")] - SimLocked(SimLockState), - - #[error("wrong PIN")] - WrongPin, - - #[error("PIN required")] - PinRequired, - - #[error("PUK required")] - PukRequired, - - #[error("bearer creation failed: {0}")] - BearerCreationFailed(String), - - #[error("connection failed: {0}")] - ConnectionFailed(String), - - #[error("modem disabled")] - ModemDisabled, - - #[error("not registered to network")] - NotRegistered, - - #[error("operation timed out")] - Timeout, - - #[error("operation cancelled")] - Cancelled, - - #[error("invalid APN: {0}")] - InvalidApn(String), - - #[error("no signal")] - NoSignal, - - #[error("roaming not allowed")] - RoamingNotAllowed, -} - -pub type Result = std::result::Result; -``` - ---- - -## 4. High-Level API (Phase 3) - -The main `ModemManager` struct and ergonomic operations. - -### 4.1 Main Entry Point - -```rust -// mmrs/src/api/modem_manager.rs - -use zbus::Connection; -use crate::{Result, Modem, Sim, Bearer, BearerConfig, ModemError}; - -/// Main entry point for ModemManager operations. -/// -/// # Example -/// -/// ```rust -/// use mmrs::ModemManager; -/// -/// #[tokio::main] -/// async fn main() -> mmrs::Result<()> { -/// let mm = ModemManager::new().await?; -/// -/// // List all modems -/// let modems = mm.list_modems().await?; -/// for modem in &modems { -/// println!("{}: {} ({})", modem.model, modem.equipment_identifier, modem.state); -/// } -/// -/// // Quick connect using Simple interface -/// mm.connect_simple("internet.apn.com").await?; -/// -/// Ok(()) -/// } -/// ``` -pub struct ModemManager { - conn: Connection, -} - -impl ModemManager { - /// Connect to the system D-Bus and ModemManager. - pub async fn new() -> Result; - - /// Use an existing D-Bus connection. - pub async fn with_connection(conn: Connection) -> Result; - - /// Get the D-Bus connection for advanced use. - pub fn connection(&self) -> &Connection; - - // --- Modem Enumeration --- - - /// List all modems. - pub async fn list_modems(&self) -> Result>; - - /// Get a specific modem by equipment identifier (IMEI). - pub async fn modem_by_imei(&self, imei: &str) -> Result; - - /// Get the first/primary modem (convenience for single-modem systems). - pub async fn primary_modem(&self) -> Result; - - /// Get a scoped API for a specific modem. - pub fn modem(&self, path: &str) -> ModemScope<'_>; - - // --- Simple Operations (primary modem) --- - - /// Enable the primary modem. - pub async fn enable(&self) -> Result<()>; - - /// Disable the primary modem. - pub async fn disable(&self) -> Result<()>; - - /// Quick connect: enable, register, create bearer, connect. - /// Uses the Simple.Connect interface. - pub async fn connect_simple(&self, apn: &str) -> Result; - - /// Connect with full bearer configuration. - pub async fn connect(&self, config: &BearerConfig) -> Result; - - /// Disconnect all data sessions. - pub async fn disconnect(&self) -> Result<()>; - - /// Get current connection status. - pub async fn status(&self) -> Result; - - // --- SIM Operations (primary modem) --- - - /// Get SIM information. - pub async fn sim(&self) -> Result>; - - /// Unlock SIM with PIN. - pub async fn unlock_pin(&self, pin: &str) -> Result<()>; - - /// Unlock SIM with PUK and set new PIN. - pub async fn unlock_puk(&self, puk: &str, new_pin: &str) -> Result<()>; - - /// Enable or disable PIN lock. - pub async fn set_pin_enabled(&self, pin: &str, enabled: bool) -> Result<()>; - - /// Change PIN. - pub async fn change_pin(&self, old_pin: &str, new_pin: &str) -> Result<()>; - - // --- Signal & Registration --- - - /// Get current signal quality (0-100). - pub async fn signal_quality(&self) -> Result; - - /// Get detailed signal information. - pub async fn signal_info(&self) -> Result; - - /// Get current access technology. - pub async fn access_technology(&self) -> Result; - - /// Get 3GPP registration info. - pub async fn registration_info(&self) -> Result; - - // --- Bearer Management --- - - /// List active bearers. - pub async fn list_bearers(&self) -> Result>; - - /// Create a bearer without connecting it. - pub async fn create_bearer(&self, config: &BearerConfig) -> Result; - - /// Delete a bearer. - pub async fn delete_bearer(&self, path: &str) -> Result<()>; - - // --- Monitoring --- - - /// Stream of modem state changes. - pub async fn monitor_state(&self) -> Result>; - - /// Stream of signal quality updates. - pub async fn monitor_signal(&self) -> Result>; - - // --- Advanced --- - - /// Send an AT command directly. - pub async fn at_command(&self, cmd: &str, timeout_secs: u32) -> Result; - - /// Reset the modem. - pub async fn reset(&self) -> Result<()>; - - /// ModemManager daemon version. - pub async fn version(&self) -> Result; -} -``` - -### 4.2 Per-Modem Scoped API - -```rust -// mmrs/src/api/modem_scope.rs - -/// Scoped operations on a specific modem. -/// -/// Obtained via `mm.modem("/org/freedesktop/ModemManager1/Modem/0")`. -pub struct ModemScope<'a> { - mm: &'a ModemManager, - path: String, -} - -impl<'a> ModemScope<'a> { - /// Get modem information. - pub async fn info(&self) -> Result; - - /// Enable this modem. - pub async fn enable(&self) -> Result<()>; - - /// Disable this modem. - pub async fn disable(&self) -> Result<()>; - - /// Simple connect. - pub async fn connect_simple(&self, apn: &str) -> Result; - - /// Connect with config. - pub async fn connect(&self, config: &BearerConfig) -> Result; - - /// Disconnect. - pub async fn disconnect(&self) -> Result<()>; - - // ... all ModemManager methods but scoped to this modem -} -``` - ---- - -## 5. Monitoring Layer (Phase 4) - -Real-time D-Bus signal subscriptions, following `nmrs/src/monitoring/` patterns. - -### 5.1 Modem State Monitoring - -```rust -// mmrs/src/monitoring/modem.rs - -/// Modem state change event. -#[derive(Debug, Clone)] -pub struct ModemStateChange { - pub modem_path: String, - pub old_state: ModemState, - pub new_state: ModemState, - pub reason: StateChangeReason, -} - -/// Subscribe to modem state changes across all modems. -pub async fn monitor_modem_state( - conn: &Connection, -) -> Result + Send>; -``` - -### 5.2 Signal Monitoring - -```rust -// mmrs/src/monitoring/signal.rs - -/// Signal quality update. -#[derive(Debug, Clone)] -pub struct SignalUpdate { - pub modem_path: String, - pub quality: u32, - pub recent: bool, - pub access_technology: AccessTechnology, -} - -/// Subscribe to signal quality changes. -pub async fn monitor_signal( - conn: &Connection, -) -> Result + Send>; -``` - ---- - -## 6. Integration with nmrs (Phase 5) - -Optional integration points where both crates work together. - -### 6.1 Combined Connection Management - -```rust -// Could live in either crate or a third `nmrs-mmrs` integration crate - -/// Unified network manager that coordinates NM and MM. -pub struct UnifiedNetworkManager { - nm: nmrs::NetworkManager, - mm: mmrs::ModemManager, -} - -impl UnifiedNetworkManager { - /// Connect via cellular, letting NM manage the connection profile - /// while MM handles the bearer. - pub async fn connect_cellular(&self, config: &CellularConfig) -> Result<()>; - - /// Get unified network state across all transports. - pub async fn network_state(&self) -> NetworkState; -} -``` - -### 6.2 NM GSM Profile + MM Bearer Coordination - -For vehicle gateways, the typical flow is: -1. NM has a saved GSM/CDMA connection profile -2. MM handles the actual modem/bearer -3. When NM activates the profile, it talks to MM under the hood - -`mmrs` provides direct MM control for when you need to bypass NM's abstraction (PIN unlock, signal monitoring, AT commands, etc.). - ---- - -## 7. Implementation Phases - -### Phase 1: Foundation (Week 1-2) -- [ ] Create `mmrs` crate structure in workspace -- [ ] Implement D-Bus proxies: Manager, Modem, Sim, Bearer -- [ ] Basic constants and type mappings -- [ ] Unit tests for proxy generation - -### Phase 2: Core Models (Week 2-3) -- [ ] Implement all model structs with `#[non_exhaustive]` -- [ ] Error enum with all MM failure modes -- [ ] Builders for `BearerConfig` -- [ ] Documentation with examples - -### Phase 3: High-Level API (Week 3-4) -- [ ] `ModemManager` struct with `new()`, `list_modems()` -- [ ] Simple connect/disconnect flow -- [ ] SIM PIN operations -- [ ] Signal quality queries -- [ ] `ModemScope` for multi-modem systems - -### Phase 4: Monitoring (Week 4-5) -- [ ] State change streams -- [ ] Signal quality streams -- [ ] SMS incoming streams (if scope includes SMS) - -### Phase 5: Testing & CI (Week 5-6) -- [ ] Integration tests (require MM + modem hardware or `mock-modem`) -- [ ] Add `mmrs` to CI pipeline (format, clippy, semver-checks) -- [ ] Cross-compile for aarch64 -- [ ] Update Dockerfile to include `modemmanager` -- [ ] Documentation pass - -### Phase 6: Optional Extensions -- [ ] SMS send/receive -- [ ] Location/GPS services -- [ ] Voice call support -- [ ] USSD commands -- [ ] Firmware management - ---- - -## 8. Testing Strategy - -### 8.1 Mock Modem for CI - -ModemManager includes `mmcli --test` and mock modem support via `libmm-glib`. For Rust CI: - -```dockerfile -# Updated Dockerfile -FROM rust:1.95.0 - -RUN apt-get update && apt-get install -y \ - libdbus-1-dev \ - pkg-config \ - dbus \ - network-manager \ - modemmanager \ - && rm -rf /var/lib/apt/lists/* - -# Start dbus and MM with a virtual modem for testing -CMD dbus-daemon --system && \ - ModemManager --test && \ - cargo test --all-features --workspace -``` - -### 8.2 Hardware Integration Tests - -Similar to `nmrs` Wi-Fi tests with `mac80211_hwsim`: - -```rust -// mmrs/tests/integration_test.rs - -macro_rules! require_modemmanager { - () => { - if !is_modemmanager_available().await { - eprintln!("Skipping test: ModemManager not available"); - return; - } - }; -} - -macro_rules! require_modem { - ($mm:expr) => { - if $mm.list_modems().await?.is_empty() { - eprintln!("Skipping test: No modem available"); - return; - } - }; -} -``` - ---- - -## 9. Vehicle/IoT Specific Considerations - -For your vehicle gateway with 18 CAN buses: - -### 9.1 Multi-Modem Support -- Enumerate all modems, not just primary -- Per-modem scoping via `mm.modem(path)` -- Track IMEI to correlate with physical slot - -### 9.2 Resilience Patterns -- Auto-reconnect on bearer drop -- Signal-based handover decisions -- Roaming policy enforcement - -### 9.3 Typical Flow -```rust -let mm = ModemManager::new().await?; - -// Unlock SIM if needed -if let Some(sim) = mm.sim().await? { - if mm.primary_modem().await?.state == ModemState::Locked { - mm.unlock_pin("1234").await?; - } -} - -// Connect with APN -let bearer = mm.connect(&BearerConfig { - apn: "fleet.apn.com".into(), - ip_type: IpType::Ipv4v6, - allow_roaming: false, - ..Default::default() -}).await?; - -println!("Connected via {}: {}", bearer.interface, bearer.ip4_config.unwrap().address); - -// Monitor signal for handover decisions -let mut signal_stream = mm.monitor_signal().await?; -while let Some(update) = signal_stream.next().await { - if update.quality < 20 { - warn!("Low signal: {}%", update.quality); - } -} -``` - ---- - -## 10. Open Questions - -1. **SMS scope** — Should `mmrs` v0.1 include SMS, or defer to a later release? -2. **Location services** — GPS via MM is common on embedded; include or separate? -3. **NM coordination** — Should `mmrs` be usable standalone, or tightly couple to `nmrs`? -4. **eSIM provisioning** — Support for `Modem.Profile` interface (LPA)? - ---- - -## References - -- [ModemManager D-Bus API Reference](https://freedesktop.org/software/ModemManager/api/latest/ref-dbus.html) -- [mmcli man page](https://www.freedesktop.org/software/ModemManager/man/latest/mmcli.1.html) -- [nmrs BlueZ integration](nmrs/src/core/airplane.rs) — pattern to follow -- [zbus proxy macro](https://docs.rs/zbus/latest/zbus/attr.proxy.html) diff --git a/scripts/ci/run-networkmanager-tests.sh b/scripts/ci/run-networkmanager-tests.sh index 46f09b2c..9702e913 100755 --- a/scripts/ci/run-networkmanager-tests.sh +++ b/scripts/ci/run-networkmanager-tests.sh @@ -54,10 +54,10 @@ export NMRS_REQUIRE_NETWORKMANAGER=1 case "${1:-all}" in all) - cargo test --all-features --workspace + cargo test --locked --all-features --workspace ;; integration) - cargo test --test integration_test --all-features + cargo test --locked --test integration_test --all-features ;; shell) bash From 19212ced3f22f8ea08a3fcd426ce473420e0688f Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Fri, 17 Jul 2026 23:36:39 -0400 Subject: [PATCH 03/10] ci: run virtual WiFi integration tests on self-hosted runner --- .github/workflows/ci.yml | 17 +++- AGENTS.md | 2 +- CONTRIBUTING.md | 8 ++ Dockerfile | 2 + docker-compose.yml | 14 +++ docs/src/development/testing.md | 15 +++ nmrs/tests/integration_test.rs | 49 ++++++++- scripts/ci/run-networkmanager-tests.sh | 136 +++++++++++++++++++++++-- 8 files changed, 225 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27f9e4f9..61e84c9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,14 +97,21 @@ jobs: run: cargo test --locked --doc --all-features --workspace integration: - name: NetworkManager Integration - runs-on: ubuntu-latest - timeout-minutes: 15 + name: NetworkManager WiFi Integration + runs-on: self-hosted + timeout-minutes: 20 steps: - uses: actions/checkout@v7 - - name: Run integration tests with NetworkManager - run: docker compose run --build --rm test-integration + - name: Load virtual WiFi radios + run: sudo modprobe mac80211_hwsim radios=2 + + - name: Run integration tests with NetworkManager and virtual WiFi + run: docker compose run --build --rm test-wifi-integration + + - name: Unload virtual WiFi radios + if: always() + run: sudo modprobe -r mac80211_hwsim || true semver: name: Semantic Versioning Check diff --git a/AGENTS.md b/AGENTS.md index 25e701fe..55c18076 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ cargo test -p nmrs --lib --all-features # unit tests only cargo test --doc --all-features --workspace # doc tests cargo test --all-features --workspace # unit + integration (needs NM + wifi hardware) cargo test --test integration_test --all-features # integration only +docker compose run --build --rm test-wifi-integration # CI-equivalent virtual WiFi tests (Linux) ``` Integration tests require wifi hardware or `mac80211_hwsim`: @@ -81,4 +82,3 @@ Sections: `Added`, `Changed`, `Fixed`. Link PRs/issues in parentheses. ## Things to watch out for - The `VpnCredentials` type is deprecated — prefer `WireGuardConfig` for new WireGuard code. - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9aa699e1..b5ede1f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,6 +100,14 @@ cargo test --test integration_test --all-features sudo modprobe -r mac80211_hwsim ``` +For the same virtual-radio setup used in CI, on a Linux host with Docker: + +```bash +sudo modprobe mac80211_hwsim radios=2 +docker compose run --build --rm test-wifi-integration +sudo modprobe -r mac80211_hwsim +``` + > [!NOTE] > > This method only works on linux diff --git a/Dockerfile b/Dockerfile index 4b6dc5cb..02431a83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,8 @@ RUN apt-get update && apt-get install -y \ libdbus-1-dev \ pkg-config \ dbus \ + hostapd \ + iw \ network-manager \ && rm -rf /var/lib/apt/lists/* diff --git a/docker-compose.yml b/docker-compose.yml index 59b85b83..50d9bbf7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,20 @@ services: - RUST_BACKTRACE=1 command: bash scripts/ci/run-networkmanager-tests.sh integration + test-wifi-integration: + build: + context: . + dockerfile: Dockerfile + privileged: true + network_mode: host + volumes: + - .:/app + - cargo-cache:/root/.cargo/registry + - target-cache:/app/target + environment: + - RUST_BACKTRACE=1 + command: bash scripts/ci/run-networkmanager-tests.sh wifi-integration + shell: build: context: . diff --git a/docs/src/development/testing.md b/docs/src/development/testing.md index a1747d1f..4898a3cc 100644 --- a/docs/src/development/testing.md +++ b/docs/src/development/testing.md @@ -115,6 +115,21 @@ This starts a private system D-Bus and NetworkManager instance, waits for it to be ready, and fails if tests cannot connect to the daemon. Wi-Fi-specific tests continue to skip until the test environment has a Wi-Fi device. +### Virtual Wi-Fi Integration + +On a Linux host, the CI-equivalent test target creates two virtual radios with +`mac80211_hwsim`. One radio advertises a WPA-PSK test network using `hostapd`; +NetworkManager manages the other radio and scans for that access point. + +```bash +sudo modprobe mac80211_hwsim radios=2 +docker compose run --build --rm test-wifi-integration +sudo modprobe -r mac80211_hwsim +``` + +This service uses host networking and is therefore intended for Linux hosts and +the GitHub Actions runner, not Docker Desktop. + ## CI/CD Tests run automatically via GitHub Actions on every push and pull request. The CI workflow: diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index fe67924f..72f08e28 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -41,10 +41,13 @@ macro_rules! require_networkmanager { }; } -/// Skip tests if WiFi device is not available +/// Skip tests if WiFi device is not available outside the WiFi integration harness. macro_rules! require_wifi { ($nm:expr) => { if !has_wifi_device($nm).await { + if std::env::var_os("NMRS_REQUIRE_WIFI").is_some() { + panic!("WiFi is required but no WiFi device is available"); + } eprintln!("Skipping test: No WiFi device available"); return; } @@ -276,6 +279,50 @@ async fn test_list_networks() { } } +/// Ensure the virtual access point is visible when the WiFi integration harness runs. +#[tokio::test] +#[serial] +async fn test_hwsim_access_point_is_discovered() { + let expected_ssid = match std::env::var("NMRS_EXPECT_WIFI_SSID") { + Ok(ssid) => ssid, + Err(_) => return, + }; + + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + require_wifi!(&nm); + + nm.set_wireless_enabled(true) + .await + .expect("Failed to enable WiFi"); + + for _ in 0..3 { + nm.scan_networks(None) + .await + .expect("Failed to scan for the virtual access point"); + sleep(Duration::from_secs(2)).await; + + let networks = nm + .list_networks(None) + .await + .expect("Failed to list scanned networks"); + + if let Some(network) = networks + .iter() + .find(|network| network.ssid == expected_ssid) + { + assert!(network.secured, "The virtual access point must be secured"); + assert!(network.is_psk, "The virtual access point must use WPA-PSK"); + return; + } + } + + panic!("The virtual access point {expected_ssid:?} was not discovered"); +} + /// Test getting current SSID #[tokio::test] #[serial] diff --git a/scripts/ci/run-networkmanager-tests.sh b/scripts/ci/run-networkmanager-tests.sh index 9702e913..5f301105 100755 --- a/scripts/ci/run-networkmanager-tests.sh +++ b/scripts/ci/run-networkmanager-tests.sh @@ -2,32 +2,136 @@ set -euo pipefail +readonly mode="${1:-all}" readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly project_root="$(cd "${script_dir}/../.." && pwd)" -readonly networkmanager_log="${TMPDIR:-/tmp}/nmrs-networkmanager.log" +readonly runtime_dir="$(mktemp -d "${TMPDIR:-/tmp}/nmrs-integration.XXXXXX")" +readonly networkmanager_log="${runtime_dir}/networkmanager.log" +readonly hostapd_log="${runtime_dir}/hostapd.log" +readonly hostapd_config="${runtime_dir}/hostapd.conf" +readonly networkmanager_config="${runtime_dir}/NetworkManager.conf" networkmanager_pid="" +hostapd_pid="" +hwsim_station_interface="" -cleanup() { - if [[ -n "${networkmanager_pid}" ]] && kill -0 "${networkmanager_pid}" 2>/dev/null; then - kill "${networkmanager_pid}" || true - wait "${networkmanager_pid}" || true +stop_process() { + local pid="$1" + + if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then + kill "${pid}" || true + wait "${pid}" || true fi } +cleanup() { + stop_process "${networkmanager_pid}" + stop_process "${hostapd_pid}" + rm -rf "${runtime_dir}" +} + print_networkmanager_log() { echo "NetworkManager did not become ready. Its log follows:" >&2 cat "${networkmanager_log}" >&2 || true } +print_hostapd_log() { + echo "hostapd did not become ready. Its log follows:" >&2 + cat "${hostapd_log}" >&2 || true +} + +setup_hwsim_access_point() { + local ap_interface + local -a wifi_interfaces + + mapfile -t wifi_interfaces < <(iw dev | awk '$1 == "Interface" { print $2 }' | sort) + if (( ${#wifi_interfaces[@]} < 2 )); then + echo "Expected two mac80211_hwsim interfaces, found ${#wifi_interfaces[@]}" >&2 + iw dev >&2 || true + exit 1 + fi + + ap_interface="${wifi_interfaces[0]}" + hwsim_station_interface="${wifi_interfaces[1]}" + + printf '%s\n' \ + "interface=${ap_interface}" \ + 'driver=nl80211' \ + 'ssid=nmrs-hwsim' \ + 'hw_mode=g' \ + 'channel=1' \ + 'wpa=2' \ + 'wpa_passphrase=nmrs-hwsim-password' \ + 'wpa_key_mgmt=WPA-PSK' \ + 'rsn_pairwise=CCMP' >"${hostapd_config}" + + hostapd "${hostapd_config}" >"${hostapd_log}" 2>&1 & + hostapd_pid=$! + + for _ in $(seq 1 15); do + if grep --quiet 'AP-ENABLED' "${hostapd_log}"; then + break + fi + + if ! kill -0 "${hostapd_pid}" 2>/dev/null; then + print_hostapd_log + exit 1 + fi + + sleep 1 + done + + if ! grep --quiet 'AP-ENABLED' "${hostapd_log}"; then + print_hostapd_log + exit 1 + fi + + # Keep NetworkManager away from the runner's interfaces and AP radio. + printf '%s\n' \ + '[main]' \ + 'plugins=keyfile' \ + 'no-auto-default=*' \ + 'auth-polkit=root-only' \ + 'dhcp=internal' \ + '' \ + '[device-hwsim-station]' \ + "match-device=interface-name:${hwsim_station_interface}" \ + 'managed=1' \ + 'stop-match=yes' \ + '' \ + '[device-default]' \ + 'match-device=*' \ + 'managed=0' \ + 'stop-match=yes' >"${networkmanager_config}" +} + trap cleanup EXIT +case "${mode}" in + all|integration|wifi-integration|shell) ;; + *) + echo "Usage: $0 [all|integration|wifi-integration|shell]" >&2 + exit 2 + ;; +esac + +if [[ "${mode}" == "wifi-integration" ]]; then + setup_hwsim_access_point +fi + mkdir -p /run/dbus dbus-daemon \ --config-file="${project_root}/scripts/ci/dbus-system.conf" \ --fork \ --nopidfile -NetworkManager --no-daemon --log-level=INFO >"${networkmanager_log}" 2>&1 & +if [[ "${mode}" == "wifi-integration" ]]; then + NetworkManager \ + --config="${networkmanager_config}" \ + --no-daemon \ + --log-level=INFO >"${networkmanager_log}" 2>&1 & +else + NetworkManager --no-daemon --log-level=INFO >"${networkmanager_log}" 2>&1 & +fi networkmanager_pid=$! for _ in $(seq 1 30); do @@ -52,18 +156,28 @@ nmcli general status export NMRS_REQUIRE_NETWORKMANAGER=1 -case "${1:-all}" in +if [[ "${mode}" == "wifi-integration" ]]; then + if ! nmcli --terse --fields DEVICE,TYPE device status | grep --fixed-strings --quiet "${hwsim_station_interface}:wifi"; then + echo "NetworkManager did not detect ${hwsim_station_interface} as Wi-Fi" >&2 + nmcli device status >&2 || true + exit 1 + fi + + export NMRS_REQUIRE_WIFI=1 + export NMRS_EXPECT_WIFI_SSID=nmrs-hwsim +fi + +case "${mode}" in all) cargo test --locked --all-features --workspace ;; integration) cargo test --locked --test integration_test --all-features ;; + wifi-integration) + cargo test --locked --test integration_test --all-features + ;; shell) bash ;; - *) - echo "Usage: $0 [all|integration|shell]" >&2 - exit 2 - ;; esac From 960f3c193105fc3e30bba8ff83c65d45a357ee06 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Fri, 17 Jul 2026 23:47:49 -0400 Subject: [PATCH 04/10] ci: block forked PRs --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61e84c9a..d2a22ef2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,9 @@ jobs: integration: name: NetworkManager WiFi Integration - runs-on: self-hosted + runs-on: [self-hosted, linux, x64] + # Self-hosted runners must not execute code supplied by forked pull requests. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository timeout-minutes: 20 steps: - uses: actions/checkout@v7 From a27a8c96d2c11414f0639326308e62b03cef96b6 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Fri, 17 Jul 2026 23:55:53 -0400 Subject: [PATCH 05/10] ci: gated aproval for wifi integration tests --- .github/workflows/ci.yml | 5 +++-- docs/src/development/testing.md | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2a22ef2..5b042a01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,8 +99,9 @@ jobs: integration: name: NetworkManager WiFi Integration runs-on: [self-hosted, linux, x64] - # Self-hosted runners must not execute code supplied by forked pull requests. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + # Pull requests wait for a maintainer to approve this environment. + environment: + name: ${{ github.event_name == 'pull_request' && 'self-hosted-pr-integration' || 'self-hosted-integration' }} timeout-minutes: 20 steps: - uses: actions/checkout@v7 diff --git a/docs/src/development/testing.md b/docs/src/development/testing.md index 4898a3cc..44bc6c83 100644 --- a/docs/src/development/testing.md +++ b/docs/src/development/testing.md @@ -130,6 +130,19 @@ sudo modprobe -r mac80211_hwsim This service uses host networking and is therefore intended for Linux hosts and the GitHub Actions runner, not Docker Desktop. +### Approving Wi-Fi CI Runs + +Pull requests send the virtual Wi-Fi job to the `self-hosted-pr-integration` +GitHub Actions environment before it is assigned to the self-hosted runner. To +require manual approval, create that environment in the repository's **Settings +> Environments**, add yourself as a required reviewer, and leave **Prevent +self-review** disabled. The job will show **Waiting for review** in the Actions +run; select **Review deployments** and approve that environment to start only +the Wi-Fi integration job. + +Pushes to `master` use the unprotected `self-hosted-integration` environment +and start automatically. + ## CI/CD Tests run automatically via GitHub Actions on every push and pull request. The CI workflow: From f6cc18dd8b41399c27b3456d9c34b7552ffa121f Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sat, 18 Jul 2026 00:05:08 -0400 Subject: [PATCH 06/10] ci: bypass sudo on modprobe --- .github/workflows/ci.yml | 4 ++-- docs/src/development/testing.md | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b042a01..998b2538 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,14 +107,14 @@ jobs: - uses: actions/checkout@v7 - name: Load virtual WiFi radios - run: sudo modprobe mac80211_hwsim radios=2 + run: sudo -n modprobe mac80211_hwsim radios=2 - name: Run integration tests with NetworkManager and virtual WiFi run: docker compose run --build --rm test-wifi-integration - name: Unload virtual WiFi radios if: always() - run: sudo modprobe -r mac80211_hwsim || true + run: sudo -n modprobe -r mac80211_hwsim || true semver: name: Semantic Versioning Check diff --git a/docs/src/development/testing.md b/docs/src/development/testing.md index 44bc6c83..22359573 100644 --- a/docs/src/development/testing.md +++ b/docs/src/development/testing.md @@ -130,6 +130,10 @@ sudo modprobe -r mac80211_hwsim This service uses host networking and is therefore intended for Linux hosts and the GitHub Actions runner, not Docker Desktop. +The self-hosted runner service account needs passwordless `sudo` permission for +`modprobe mac80211_hwsim radios=2` and `modprobe -r mac80211_hwsim`; CI invokes +both commands with `sudo -n`. + ### Approving Wi-Fi CI Runs Pull requests send the virtual Wi-Fi job to the `self-hosted-pr-integration` From fa9a5c1d6ada7983c6ec372853c491decffab14d Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sat, 18 Jul 2026 00:26:04 -0400 Subject: [PATCH 07/10] fix: initialize virtual WiFi station before integration tests --- Dockerfile | 1 + nmrs/src/core/device.rs | 25 ++++++++--- nmrs/src/core/scan.rs | 5 +++ nmrs/tests/integration_test.rs | 9 ++-- scripts/ci/dbus-system.conf | 1 + scripts/ci/run-networkmanager-tests.sh | 60 +++++++++++++++++++++++++- 6 files changed, 91 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 02431a83..0dcbc55e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y \ hostapd \ iw \ network-manager \ + wpasupplicant \ && rm -rf /var/lib/apt/lists/* COPY Cargo.toml Cargo.lock ./ diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 34cb98e1..116e2581 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -387,8 +387,10 @@ pub(crate) async fn list_bluetooth_devices(conn: &Connection) -> Result Result<()> { let nm = NMProxy::new(conn).await?; let devices = nm.get_devices().await?; + let mut pending_wifi_device = None; + let mut found_wifi_device = false; - // Find the Wi-Fi device + // Prefer a ready device. An unmanaged radio can appear before a usable one. for dev_path in devices { let dev = NMDeviceProxy::builder(conn) .path(dev_path.clone())? @@ -399,9 +401,10 @@ pub(crate) async fn wait_for_wifi_ready(conn: &Connection) -> Result<()> { continue; } - debug!("Found Wi-Fi device, waiting for it to become ready"); + found_wifi_device = true; + + debug!("Found Wi-Fi device, checking whether it is ready"); - // Check current state first let current_state = dev.state().await?; let state = DeviceState::from(current_state); @@ -410,11 +413,23 @@ pub(crate) async fn wait_for_wifi_ready(conn: &Connection) -> Result<()> { return Ok(()); } - // Wait for device to become ready using signal-based monitoring + if !matches!(state, DeviceState::Unmanaged | DeviceState::Unavailable) + && pending_wifi_device.is_none() + { + pending_wifi_device = Some(dev_path); + } + } + + if let Some(dev_path) = pending_wifi_device { + let dev = NMDeviceProxy::builder(conn).path(dev_path)?.build().await?; return wait_for_wifi_device_ready(&dev).await; } - Err(ConnectionError::NoWifiDevice) + if found_wifi_device { + Err(ConnectionError::WifiNotReady) + } else { + Err(ConnectionError::NoWifiDevice) + } } #[cfg(test)] diff --git a/nmrs/src/core/scan.rs b/nmrs/src/core/scan.rs index 02e0a8ce..b7960b88 100644 --- a/nmrs/src/core/scan.rs +++ b/nmrs/src/core/scan.rs @@ -53,6 +53,11 @@ pub(crate) async fn scan_networks(conn: &Connection, interface: Option<&str>) -> if iface != want { continue; } + } else { + let state = DeviceState::from(d_proxy.state().await?); + if matches!(state, DeviceState::Unmanaged | DeviceState::Unavailable) { + continue; + } } let wifi = NMWirelessProxy::builder(conn) diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 72f08e28..8433a3d7 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -287,6 +287,8 @@ async fn test_hwsim_access_point_is_discovered() { Ok(ssid) => ssid, Err(_) => return, }; + let interface = std::env::var("NMRS_WIFI_INTERFACE") + .expect("WiFi integration harness did not provide the station interface"); require_networkmanager!(); @@ -299,14 +301,15 @@ async fn test_hwsim_access_point_is_discovered() { .await .expect("Failed to enable WiFi"); + let wifi = nm.wifi(&interface); for _ in 0..3 { - nm.scan_networks(None) + wifi.scan() .await .expect("Failed to scan for the virtual access point"); sleep(Duration::from_secs(2)).await; - let networks = nm - .list_networks(None) + let networks = wifi + .list_networks() .await .expect("Failed to list scanned networks"); diff --git a/scripts/ci/dbus-system.conf b/scripts/ci/dbus-system.conf index cd6b1793..e096b396 100644 --- a/scripts/ci/dbus-system.conf +++ b/scripts/ci/dbus-system.conf @@ -5,6 +5,7 @@ system unix:path=/run/dbus/system_bus_socket EXTERNAL + diff --git a/scripts/ci/run-networkmanager-tests.sh b/scripts/ci/run-networkmanager-tests.sh index 5f301105..8754d471 100755 --- a/scripts/ci/run-networkmanager-tests.sh +++ b/scripts/ci/run-networkmanager-tests.sh @@ -8,10 +8,12 @@ readonly project_root="$(cd "${script_dir}/../.." && pwd)" readonly runtime_dir="$(mktemp -d "${TMPDIR:-/tmp}/nmrs-integration.XXXXXX")" readonly networkmanager_log="${runtime_dir}/networkmanager.log" readonly hostapd_log="${runtime_dir}/hostapd.log" +readonly wpa_supplicant_log="${runtime_dir}/wpa_supplicant.log" readonly hostapd_config="${runtime_dir}/hostapd.conf" readonly networkmanager_config="${runtime_dir}/NetworkManager.conf" networkmanager_pid="" hostapd_pid="" +wpa_supplicant_pid="" hwsim_station_interface="" stop_process() { @@ -25,6 +27,7 @@ stop_process() { cleanup() { stop_process "${networkmanager_pid}" + stop_process "${wpa_supplicant_pid}" stop_process "${hostapd_pid}" rm -rf "${runtime_dir}" } @@ -39,6 +42,45 @@ print_hostapd_log() { cat "${hostapd_log}" >&2 || true } +print_wpa_supplicant_log() { + echo "wpa_supplicant did not become ready. Its log follows:" >&2 + cat "${wpa_supplicant_log}" >&2 || true +} + +start_wpa_supplicant() { + mkdir -p /run/wpa_supplicant + + wpa_supplicant \ + -u \ + -s \ + -O /run/wpa_supplicant \ + -f "${wpa_supplicant_log}" > /dev/null 2>&1 & + wpa_supplicant_pid=$! + + for _ in $(seq 1 15); do + if dbus-send \ + --system \ + --dest=org.freedesktop.DBus \ + --type=method_call \ + --print-reply \ + /org/freedesktop/DBus \ + org.freedesktop.DBus.NameHasOwner \ + string:fi.w1.wpa_supplicant1 2>/dev/null | grep --quiet 'boolean true'; then + return + fi + + if ! kill -0 "${wpa_supplicant_pid}" 2>/dev/null; then + print_wpa_supplicant_log + exit 1 + fi + + sleep 1 + done + + print_wpa_supplicant_log + exit 1 +} + setup_hwsim_access_point() { local ap_interface local -a wifi_interfaces @@ -125,6 +167,8 @@ dbus-daemon \ --nopidfile if [[ "${mode}" == "wifi-integration" ]]; then + start_wpa_supplicant + NetworkManager \ --config="${networkmanager_config}" \ --no-daemon \ @@ -157,14 +201,26 @@ nmcli general status export NMRS_REQUIRE_NETWORKMANAGER=1 if [[ "${mode}" == "wifi-integration" ]]; then - if ! nmcli --terse --fields DEVICE,TYPE device status | grep --fixed-strings --quiet "${hwsim_station_interface}:wifi"; then - echo "NetworkManager did not detect ${hwsim_station_interface} as Wi-Fi" >&2 + for _ in $(seq 1 30); do + station_state="$(nmcli --terse --fields DEVICE,TYPE,STATE device status | awk -F: -v interface="${hwsim_station_interface}" '$1 == interface && $2 == "wifi" { print $3; exit }')" + if [[ "${station_state}" == "disconnected" || "${station_state}" == "connected" ]]; then + break + fi + + sleep 1 + done + + if [[ "${station_state:-}" != "disconnected" && "${station_state:-}" != "connected" ]]; then + echo "NetworkManager did not make ${hwsim_station_interface} ready for scanning" >&2 nmcli device status >&2 || true + print_networkmanager_log + print_wpa_supplicant_log exit 1 fi export NMRS_REQUIRE_WIFI=1 export NMRS_EXPECT_WIFI_SSID=nmrs-hwsim + export NMRS_WIFI_INTERFACE="${hwsim_station_interface}" fi case "${mode}" in From 4399c8c630dea95decf842ce226f9b21823d0e63 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sat, 18 Jul 2026 00:31:45 -0400 Subject: [PATCH 08/10] fix: reset and isolate virtual WiFi radios in CI --- .github/workflows/ci.yml | 4 +++- Dockerfile | 1 + scripts/ci/run-networkmanager-tests.sh | 26 ++++++++++++++------------ 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 998b2538..72fdbc63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,9 @@ jobs: - uses: actions/checkout@v7 - name: Load virtual WiFi radios - run: sudo -n modprobe mac80211_hwsim radios=2 + run: | + sudo -n modprobe -r mac80211_hwsim || true + sudo -n modprobe mac80211_hwsim radios=2 - name: Run integration tests with NetworkManager and virtual WiFi run: docker compose run --build --rm test-wifi-integration diff --git a/Dockerfile b/Dockerfile index 0dcbc55e..44e46556 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ RUN apt-get update && apt-get install -y \ libdbus-1-dev \ pkg-config \ dbus \ + ethtool \ hostapd \ iw \ network-manager \ diff --git a/scripts/ci/run-networkmanager-tests.sh b/scripts/ci/run-networkmanager-tests.sh index 8754d471..8b963538 100755 --- a/scripts/ci/run-networkmanager-tests.sh +++ b/scripts/ci/run-networkmanager-tests.sh @@ -85,9 +85,16 @@ setup_hwsim_access_point() { local ap_interface local -a wifi_interfaces - mapfile -t wifi_interfaces < <(iw dev | awk '$1 == "Interface" { print $2 }' | sort) - if (( ${#wifi_interfaces[@]} < 2 )); then - echo "Expected two mac80211_hwsim interfaces, found ${#wifi_interfaces[@]}" >&2 + mapfile -t wifi_interfaces < <( + iw dev | awk '$1 == "Interface" { print $2 }' | + while read -r interface; do + if ethtool -i "${interface}" 2>/dev/null | grep --fixed-strings --quiet 'driver: mac80211_hwsim'; then + printf '%s\n' "${interface}" + fi + done | sort + ) + if (( ${#wifi_interfaces[@]} != 2 )); then + echo "Expected exactly two mac80211_hwsim interfaces, found ${#wifi_interfaces[@]}" >&2 iw dev >&2 || true exit 1 fi @@ -135,15 +142,8 @@ setup_hwsim_access_point() { 'auth-polkit=root-only' \ 'dhcp=internal' \ '' \ - '[device-hwsim-station]' \ - "match-device=interface-name:${hwsim_station_interface}" \ - 'managed=1' \ - 'stop-match=yes' \ - '' \ - '[device-default]' \ - 'match-device=*' \ - 'managed=0' \ - 'stop-match=yes' >"${networkmanager_config}" + '[keyfile]' \ + "unmanaged-devices=*,except:interface-name:${hwsim_station_interface}" >"${networkmanager_config}" } trap cleanup EXIT @@ -213,6 +213,8 @@ if [[ "${mode}" == "wifi-integration" ]]; then if [[ "${station_state:-}" != "disconnected" && "${station_state:-}" != "connected" ]]; then echo "NetworkManager did not make ${hwsim_station_interface} ready for scanning" >&2 nmcli device status >&2 || true + nmcli -f GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.REASON,GENERAL.MANAGED \ + device show "${hwsim_station_interface}" >&2 || true print_networkmanager_log print_wpa_supplicant_log exit 1 From 190020f5c184e6094aabc4360b93fe22bb8ac33d Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sat, 18 Jul 2026 00:38:04 -0400 Subject: [PATCH 09/10] fix(ci); force manage device --- scripts/ci/run-networkmanager-tests.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run-networkmanager-tests.sh b/scripts/ci/run-networkmanager-tests.sh index 8b963538..5c99f6c0 100755 --- a/scripts/ci/run-networkmanager-tests.sh +++ b/scripts/ci/run-networkmanager-tests.sh @@ -143,7 +143,11 @@ setup_hwsim_access_point() { 'dhcp=internal' \ '' \ '[keyfile]' \ - "unmanaged-devices=*,except:interface-name:${hwsim_station_interface}" >"${networkmanager_config}" + "unmanaged-devices=*,except:interface-name:${hwsim_station_interface}" \ + '' \ + '[device-hwsim-station]' \ + "match-device=interface-name:=${hwsim_station_interface}" \ + 'managed=1' >"${networkmanager_config}" } trap cleanup EXIT @@ -201,6 +205,9 @@ nmcli general status export NMRS_REQUIRE_NETWORKMANAGER=1 if [[ "${mode}" == "wifi-integration" ]]; then + nmcli device set "${hwsim_station_interface}" managed yes + nmcli radio wifi on + for _ in $(seq 1 30); do station_state="$(nmcli --terse --fields DEVICE,TYPE,STATE device status | awk -F: -v interface="${hwsim_station_interface}" '$1 == interface && $2 == "wifi" { print $3; exit }')" if [[ "${station_state}" == "disconnected" || "${station_state}" == "connected" ]]; then @@ -213,7 +220,7 @@ if [[ "${mode}" == "wifi-integration" ]]; then if [[ "${station_state:-}" != "disconnected" && "${station_state:-}" != "connected" ]]; then echo "NetworkManager did not make ${hwsim_station_interface} ready for scanning" >&2 nmcli device status >&2 || true - nmcli -f GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.REASON,GENERAL.MANAGED \ + nmcli -f GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.REASON,GENERAL.NM-MANAGED \ device show "${hwsim_station_interface}" >&2 || true print_networkmanager_log print_wpa_supplicant_log From 146c4edbfeb7317afb51c9717e6304eaffdc0682 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sat, 18 Jul 2026 00:40:50 -0400 Subject: [PATCH 10/10] fix: mount udev --- .github/workflows/ci.yml | 1 + docker-compose.yml | 1 + docs/src/development/testing.md | 3 +++ 3 files changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72fdbc63..71926110 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,7 @@ jobs: run: | sudo -n modprobe -r mac80211_hwsim || true sudo -n modprobe mac80211_hwsim radios=2 + udevadm settle --timeout=10 - name: Run integration tests with NetworkManager and virtual WiFi run: docker compose run --build --rm test-wifi-integration diff --git a/docker-compose.yml b/docker-compose.yml index 50d9bbf7..13114481 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,7 @@ services: network_mode: host volumes: - .:/app + - /run/udev:/run/udev:ro - cargo-cache:/root/.cargo/registry - target-cache:/app/target environment: diff --git a/docs/src/development/testing.md b/docs/src/development/testing.md index 22359573..68af533c 100644 --- a/docs/src/development/testing.md +++ b/docs/src/development/testing.md @@ -130,6 +130,9 @@ sudo modprobe -r mac80211_hwsim This service uses host networking and is therefore intended for Linux hosts and the GitHub Actions runner, not Docker Desktop. +It also mounts the host's `/run/udev` read-only so NetworkManager can manage +the newly created hwsim links. + The self-hosted runner service account needs passwordless `sudo` permission for `modprobe mac80211_hwsim radios=2` and `modprobe -r mac80211_hwsim`; CI invokes both commands with `sudo -n`.