From 2e0d1e733c68500c57f1c07078057f68600fb2b9 Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Sun, 10 May 2026 23:42:33 -0600 Subject: [PATCH] Initial commit --- .github/workflows/ci.yml | 124 + .gitignore | 23 + Cargo.lock | 2243 ++++++++++++ Cargo.toml | 36 + LICENSE | 661 ++++ README.md | 226 ++ crates/lxst-core/Cargo.toml | 17 + crates/lxst-core/src/lib.rs | 31 + crates/lxst-core/src/opus.rs | 649 ++++ crates/lxst-core/src/profile.rs | 356 ++ crates/lxst-core/src/raw.rs | 204 ++ crates/lxst-core/src/stream.rs | 339 ++ crates/lxst-core/src/synthetic.rs | 249 ++ crates/lxst-core/src/telephony.rs | 378 ++ crates/lxst-core/src/wire.rs | 485 +++ crates/lxst-core/tests/malformed_wire.rs | 128 + crates/lxst-core/tests/python_raw_parity.rs | 161 + crates/lxst-core/tests/python_wire_parity.rs | 150 + crates/lxst-core/tests/reference_snapshot.rs | 82 + crates/lxst-rns/Cargo.toml | 18 + crates/lxst-rns/src/lib.rs | 659 ++++ crates/lxst-telephony/Cargo.toml | 25 + crates/lxst-telephony/src/lib.rs | 2863 +++++++++++++++ crates/lxst-telephony/src/tests.rs | 3147 +++++++++++++++++ .../tests/python_destination_parity.rs | 69 + .../tests/python_telephone_helper.rs | 110 + .../tests/python_telephone_live_interop.rs | 2965 ++++++++++++++++ 27 files changed, 16398 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 crates/lxst-core/Cargo.toml create mode 100644 crates/lxst-core/src/lib.rs create mode 100644 crates/lxst-core/src/opus.rs create mode 100644 crates/lxst-core/src/profile.rs create mode 100644 crates/lxst-core/src/raw.rs create mode 100644 crates/lxst-core/src/stream.rs create mode 100644 crates/lxst-core/src/synthetic.rs create mode 100644 crates/lxst-core/src/telephony.rs create mode 100644 crates/lxst-core/src/wire.rs create mode 100644 crates/lxst-core/tests/malformed_wire.rs create mode 100644 crates/lxst-core/tests/python_raw_parity.rs create mode 100644 crates/lxst-core/tests/python_wire_parity.rs create mode 100644 crates/lxst-core/tests/reference_snapshot.rs create mode 100644 crates/lxst-rns/Cargo.toml create mode 100644 crates/lxst-rns/src/lib.rs create mode 100644 crates/lxst-telephony/Cargo.toml create mode 100644 crates/lxst-telephony/src/lib.rs create mode 100644 crates/lxst-telephony/src/tests.rs create mode 100644 crates/lxst-telephony/tests/python_destination_parity.rs create mode 100644 crates/lxst-telephony/tests/python_telephone_helper.rs create mode 100644 crates/lxst-telephony/tests/python_telephone_live_interop.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..438225c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,124 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +env: + # Python LXST parity fixtures and the pinned upstream reference are kept in + # the internal development tree, not in this published source release. The + # corresponding tests skip themselves when this env var is set. + SKIP_PYTHON_LXST_INTEROP: "1" + +jobs: + lint: + name: Lint and Docs + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + path: rsLXST + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/rsReticulum + ref: main + path: rsReticulum + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rsLXST -> target + - name: Install Linux system deps + run: sudo apt-get update && sudo apt-get install -y libudev-dev libdbus-1-dev pkg-config libopus0 libogg0 + - run: cargo fmt --all -- --check + working-directory: rsLXST + - run: cargo clippy --workspace -- -D warnings + working-directory: rsLXST + - run: cargo doc --workspace --no-deps + working-directory: rsLXST + env: + RUSTDOCFLAGS: "-D warnings" + + desktop-test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + with: + path: rsLXST + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/rsReticulum + ref: main + path: rsReticulum + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rsLXST -> target + - name: Install Linux system deps + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libudev-dev libdbus-1-dev pkg-config libopus0 libogg0 + - name: Install macOS Opus runtime + if: runner.os == 'macOS' + run: brew install opus libogg + - run: cargo test --workspace + working-directory: rsLXST + + mobile-check: + name: Check (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: aarch64-linux-android + - os: macos-latest + target: aarch64-apple-ios + steps: + - uses: actions/checkout@v4 + with: + path: rsLXST + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/rsReticulum + ref: main + path: rsReticulum + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rsLXST -> target + - uses: nttld/setup-ndk@v1 + if: matrix.target == 'aarch64-linux-android' + id: setup-ndk + with: + ndk-version: r27d + add-to-path: false + - name: Check Android + if: matrix.target == 'aarch64-linux-android' + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + AR_aarch64_linux_android: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar + CC_aarch64_linux_android: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang + CXX_aarch64_linux_android: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang++ + run: cargo check --workspace --target ${{ matrix.target }} + working-directory: rsLXST + - name: Check iOS + if: matrix.target == 'aarch64-apple-ios' + run: cargo check --workspace --target ${{ matrix.target }} + working-directory: rsLXST diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c6ea77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +.DS_Store +target/ + +# Runtime data and local identities +.reticulum/ +.ratspeak/ +*.db +*.sqlite +*.sqlite3 +*.key +identity +storage/ + +# Python and tooling caches +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ + +# Editor and local environment files +.env +.env.* +*.swp diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..32ef96f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2243 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum", + "tokio", + "tokio-stream", + "uuid", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[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 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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-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-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 = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" +dependencies = [ + "cesu8", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lxst-core" +version = "0.1.0" +dependencies = [ + "half", + "hex", + "opus-rs", + "proptest", + "rmpv", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "lxst-rns" +version = "0.1.0" +dependencies = [ + "bytes", + "lxst-core", + "rns-crypto", + "rns-link", + "rns-transport", + "rns-wire", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "lxst-telephony" +version = "0.1.0" +dependencies = [ + "bytes", + "hex", + "lxst-core", + "lxst-rns", + "rns-crypto", + "rns-identity", + "rns-interface", + "rns-link", + "rns-runtime", + "rns-transport", + "rns-wire", + "serde_json", + "serial_test", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-core-bluetooth" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a644b62ffb826a5277f536cf0f701493de420b13d40e700c452c36567771111" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opus-rs" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6511297abde7ca183099fb30e50b8f81c3bde45ab85812f68bb4db3d97f20a0c" + +[[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" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[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 = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rns-crypto" +version = "0.9.3" +dependencies = [ + "aes", + "cbc", + "ed25519-dalek", + "hkdf", + "hmac", + "rand 0.8.6", + "rand_core 0.6.4", + "sha2", + "subtle", + "thiserror 2.0.18", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "rns-identity" +version = "0.9.3" +dependencies = [ + "hex", + "rand 0.8.6", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-wire", + "serde", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "rns-interface" +version = "0.9.3" +dependencies = [ + "bluer", + "bytes", + "hex", + "if-addrs", + "jni", + "libc", + "objc2", + "objc2-core-bluetooth", + "objc2-foundation", + "rand 0.8.6", + "rns-crypto", + "rns-transport", + "rns-wire", + "serde", + "serde_json", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows", +] + +[[package]] +name = "rns-link" +version = "0.9.3" +dependencies = [ + "hex", + "rand 0.8.6", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-wire", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "rns-protocol" +version = "0.9.3" +dependencies = [ + "bzip2", + "hex", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-link", + "rns-wire", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "rns-runtime" +version = "0.9.3" +dependencies = [ + "bytes", + "hex", + "hmac", + "nix", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-interface", + "rns-link", + "rns-protocol", + "rns-transport", + "rns-wire", + "serde", + "sha2", + "subtle", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "rns-transport" +version = "0.9.3" +dependencies = [ + "bytes", + "hex", + "rand 0.8.6", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-wire", + "serde", + "subtle", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "rns-wire" +version = "0.9.3" +dependencies = [ + "rns-crypto", + "sha2", + "thiserror 2.0.18", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[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 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[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_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[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 = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..58b230d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,36 @@ +[workspace] +resolver = "2" +members = [ + "crates/lxst-core", + "crates/lxst-rns", + "crates/lxst-telephony", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-or-later" +rust-version = "1.85" + +[workspace.dependencies] +rmpv = "1" +thiserror = "2" +serde_json = "1" +hex = "0.4" +bytes = "1" +tokio = { version = "1", features = ["sync", "time", "rt", "macros"] } +half = "2" +proptest = "1" +opus-rs = "0.1.19" +serial_test = "3" + +rns-crypto = { path = "../rsReticulum/crates/rns-crypto" } +rns-identity = { path = "../rsReticulum/crates/rns-identity" } +rns-interface = { path = "../rsReticulum/crates/rns-interface" } +rns-link = { path = "../rsReticulum/crates/rns-link" } +rns-runtime = { path = "../rsReticulum/crates/rns-runtime" } +rns-transport = { path = "../rsReticulum/crates/rns-transport" } +rns-wire = { path = "../rsReticulum/crates/rns-wire" } + +lxst-core = { path = "crates/lxst-core" } +lxst-rns = { path = "crates/lxst-rns" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3843acd --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +
+ +# rsLXST + +**Rust LXST telephony and media streaming for Reticulum.** + +[![License: AGPL-3.0-or-later](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue.svg)](LICENSE) +[![Rust 1.85+](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org) +[![LXST 0.4.5](https://img.shields.io/badge/target-LXST%200.4.5-success.svg)](https://github.com/markqvist/LXST) +[![Status](https://img.shields.io/badge/status-library-yellow.svg)](#feature-status) + +[rsLXMF](https://github.com/ratspeak/rsLXMF) | +[Ratspeak](https://github.com/ratspeak/Ratspeak) | +[rsReticulum](https://github.com/ratspeak/rsReticulum) | +[Reticulum Manual](https://reticulum.network/manual/) + +
+ +--- + +rsLXST is a Rust implementation of [LXST](https://github.com/markqvist/LXST), the Lightweight Extensible Signal +Transport used for real-time voice calls and other media streams over Reticulum. This is not a +fork of LXST; it is LXST written in a different language with interoperability +as the primary focus. Python LXST remains the source-of-truth +implementation, do not treat this repository as one. + +The current rsLXST is experimental and incomplete. It provides LXST wire codecs, +Reticulum link media packet boundaries, a telephony runtime, and Opus stream +integration for applications (such as Ratspeak). + +The first public target is interoperable Opus +telephony, not complete feature parity with +the reference implementation LXST. + +## Contents + +- [Release Scope](#release-scope) +- [Build It](#build-it) +- [Test It](#test-it) +- [Crate Layout](#crate-layout) +- [Using Telephony](#using-telephony) +- [Contributing](#contributing) +- [License](#license) + +## Release Scope + +The experimental release is to cover basic voice calls, with several features still unsupported: + +- `rnphone` parity and `rnphone-rs` usage. +- Full Codec2 support. +- Deeper audio support: microphone/source backends, filters, AGC, etc. +- Broadcast, stream, and non-telephony LXST primitives. + +Those are expected future work. They should not be implied by the first public +Opus telephony release. + +## Build It + +The current development layout requires `rsReticulum` as a sibling checkout +because rsLXST uses the Rust Reticulum crates directly: + +```text +ratspeak-src/ +|-- rsReticulum/ +`-- rsLXST/ +``` + +If you're starting fresh: + +```bash +mkdir ratspeak-src +cd ratspeak-src +git clone https://github.com/ratspeak/rsReticulum +git clone https://github.com/ratspeak/rsLXST +cd rsLXST +``` + +### macOS + +Install Rust with `rustup`, then install Apple's command-line build tools: + +```bash +xcode-select --install +``` + +Build the workspace: + +```bash +cd rsLXST +cargo build --release +``` + +### Linux / Raspberry Pi + +Install Rust with `rustup`, then install the usual build packages. + +Debian, Ubuntu, and Raspberry Pi OS: + +```bash +sudo apt update +sudo apt install -y build-essential pkg-config +``` + +Fedora: + +```bash +sudo dnf install gcc make pkgconf-pkg-config +``` + +Arch: + +```bash +sudo pacman -S --needed base-devel pkgconf +``` + +Build the workspace: + +```bash +cd rsLXST +cargo build --release +``` + +### Windows + +Install Rust with the MSVC toolchain. If Rust or Cargo asks for Visual Studio +Build Tools, install the "Desktop development with C++" workload. + +Build from PowerShell: + +```powershell +cd rsLXST +cargo build --release +``` + +## Test It + +Run the Rust-only test gate: + +```bash +SKIP_PYTHON_LXST_INTEROP=1 cargo test --workspace +``` + +Run the local CI gate: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace -- -D warnings +SKIP_PYTHON_LXST_INTEROP=1 cargo test --workspace +``` + +The Rust-only gate covers wire codecs, telephony state, profile metadata, +Opus stream boundaries, malformed-input handling, and the local service +runtime. + +Python LXST interop tests against the pinned upstream reference live in the development tree and are not +included in this published release but I am happy to provide them on request. + +## Crate Layout + +| Crate | Purpose | +| --- | --- | +| `lxst-core` | LXST constants, telephony profiles, signalling values, codec IDs, MessagePack packets, Raw audio frames, Opus encode/decode state, stream packetization, synthetic sources, and jitter buffers. This crate has no Reticulum runtime dependency. | +| `lxst-rns` | The Reticulum link-packet boundary for no-receipt LXST signalling and media over active links. It packs outbound LXST packets and decodes inbound link plaintext into typed LXST packet/frame events. | +| `lxst-telephony` | The telephony runtime and service layer. It owns call state, caller policy, Reticulum destination registration, announce discovery, outgoing link establishment, typed control/event channels, Opus transmit/receive stream boundaries, timeout handling, and shutdown teardown. | + +## Using Telephony + +Applications normally use `lxst-telephony` through `TelephonyService`, not by +manually translating Reticulum events. The service registers the local +`lxst.telephony` destination, emits startup/periodic announces, owns the call +runtime, and exposes typed control and event channels. + +```rust +use lxst_core::Profile; +use lxst_telephony::{TelephonyControl, TelephonyService}; +use tokio::time::Duration; + +let parts = TelephonyService::registered(transport_tx, &identity)?; +let control_tx = parts.control_tx.clone(); +let mut event_rx = parts.event_rx; + +tokio::spawn(parts.service.run()); + +control_tx + .send(TelephonyControl::Call { + remote_identity, + profile: Some(Profile::QualityMedium), + discovery_timeout: Duration::from_secs(8), + }) + .await?; +``` + +The service event stream is the app-facing state source. Use +`TelephonyServiceEvent::Snapshot`, `IncomingCall`, `OutgoingCallStarted`, +`CallTerminated`, stream lifecycle events, and media events instead of +inferring call state from raw Reticulum traffic. + +For Opus calls, applications supply and receive `RawAudioFrame` values through +`StartOpusStream` and `StartOpusReceiveStream`. rsLXST enforces the negotiated +LXST profile and reports profile changes, source/sink closure, frame drops, and +call-end stream shutdown explicitly. + +Applications still own platform integration: + +- contact or peer lookup +- UI and call controls +- microphone/camera/speaker permissions +- device selection +- audio session lifecycle +- capture/playback and resampling into `RawAudioFrame` +- settings persistence +- mobile foreground/background behavior + +Ratspeak uses this boundary for its native voice-call feature. + +## Contributing + +If the issue or contribution belongs upstream as well, start there. Python LXST +and Reticulum remain the reference implementations. + +PRs are closed for now until I have time to catch up on everything. + +## License + +Licensed under the GNU Affero General +Public License v3.0 or later. See [LICENSE](LICENSE). diff --git a/crates/lxst-core/Cargo.toml b/crates/lxst-core/Cargo.toml new file mode 100644 index 0000000..3853dbe --- /dev/null +++ b/crates/lxst-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "lxst-core" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +half.workspace = true +opus-rs.workspace = true +rmpv.workspace = true +thiserror.workspace = true + +[dev-dependencies] +hex.workspace = true +proptest.workspace = true +serde_json.workspace = true diff --git a/crates/lxst-core/src/lib.rs b/crates/lxst-core/src/lib.rs new file mode 100644 index 0000000..f548d06 --- /dev/null +++ b/crates/lxst-core/src/lib.rs @@ -0,0 +1,31 @@ +//! Core LXST wire types. +//! +//! This crate owns the byte-level contract with Python LXST. It deliberately +//! avoids audio and Reticulum runtime dependencies so packet/profile parity can +//! be tested in isolation. + +mod opus; +mod profile; +mod raw; +mod stream; +mod synthetic; +mod telephony; +mod wire; + +pub use opus::{OpusCodecError, OpusDecoderState, OpusEncoderState}; +pub use profile::{AudioCodec, OpusApplication, OpusProfile, Profile, SignallingStatus}; +pub use raw::RawAudioFrame; +pub use stream::{ + DropPolicy, FramePacketizer, FrameStreamEvent, FrameStreamState, JitterBuffer, JitterPush, + JitterStats, StreamError, +}; +pub use synthetic::{RawFrameCollector, SyntheticError, SyntheticSource, SyntheticSourceKind}; +pub use telephony::{CallRole, TelephonyAction, TelephonyCall}; +pub use wire::{ + Codec2Mode, CodecKind, Error, FIELD_FRAMES, FIELD_SIGNALLING, Frame, LxstPacket, RawBitDepth, + RawFrameHeader, Signal, +}; + +pub const APP_NAME: &str = "lxst"; +pub const TELEPHONY_PRIMITIVE_NAME: &str = "telephony"; +pub const TELEPHONY_DESTINATION_NAME: &str = "lxst.telephony"; diff --git a/crates/lxst-core/src/opus.rs b/crates/lxst-core/src/opus.rs new file mode 100644 index 0000000..263c693 --- /dev/null +++ b/crates/lxst-core/src/opus.rs @@ -0,0 +1,649 @@ +use opus_rs::{Application, OpusDecoder, OpusEncoder}; +use thiserror::Error; + +use crate::{AudioCodec, CodecKind, Frame, OpusApplication, OpusProfile, Profile, RawAudioFrame}; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum OpusCodecError { + #[error("profile {0:?} does not use Opus")] + NonOpusProfile(Profile), + #[error("Opus frame channel count {actual} does not match profile channel count {expected}")] + ChannelMismatch { expected: u8, actual: u8 }, + #[error("Opus frame sample count {actual} does not match profile sample count {expected}")] + SampleFrameMismatch { expected: usize, actual: usize }, + #[error( + "Opus frame duration is not supported by the current encoder: {sample_rate_hz} Hz / {sample_frames} samples" + )] + UnsupportedFrameDuration { + sample_rate_hz: u32, + sample_frames: usize, + }, + #[error("invalid Opus frame codec {0:?}")] + InvalidFrameCodec(CodecKind), + #[error("Opus subframe payload length {0} exceeds the supported packet length encoding")] + UnsupportedSubframePayloadLength(usize), + #[error("Opus encoder returned an unsupported subpacket layout")] + UnsupportedSubpacketLayout, + #[error("malformed Opus packet: {0}")] + MalformedPacket(&'static str), + #[error("Opus codec error: {0}")] + Codec(String), + #[error("LXST wire error: {0}")] + Wire(#[from] crate::Error), +} + +pub struct OpusEncoderState { + profile: Profile, + channels: u8, + sample_frames: usize, + subframe_count: usize, + subframe_sample_frames: usize, + encode_sample_frames: usize, + encode_subframe_sample_frames: usize, + max_payload_bytes: usize, + encoder: OpusEncoder, +} + +impl OpusEncoderState { + pub fn new(profile: Profile) -> Result { + let opus_profile = match profile.audio_codec() { + AudioCodec::Opus(profile) => profile, + AudioCodec::Codec2(_) => return Err(OpusCodecError::NonOpusProfile(profile)), + }; + let channels = opus_profile.channels(); + let sample_rate = opus_profile.sample_rate(); + let encode_sample_rate = encode_sample_rate(opus_profile); + let sample_frames = profile.sample_frames_per_packet(); + let encode_sample_frames = + scale_sample_frames(sample_frames, sample_rate, encode_sample_rate)?; + let packet_layout = PacketLayout::new(encode_sample_rate, encode_sample_frames)?; + let subframe_sample_frames = sample_frames + .checked_div(packet_layout.subframe_count) + .filter(|frames| frames * packet_layout.subframe_count == sample_frames) + .ok_or(OpusCodecError::UnsupportedFrameDuration { + sample_rate_hz: sample_rate, + sample_frames, + })?; + let mut encoder = OpusEncoder::new( + encode_sample_rate as i32, + usize::from(channels), + opus_application(opus_profile.application()), + ) + .map_err(|err| OpusCodecError::Codec(err.to_string()))?; + encoder.bitrate_bps = opus_profile.bitrate_ceiling() as i32; + encoder.use_cbr = false; + + Ok(Self { + profile, + channels, + sample_frames, + subframe_count: packet_layout.subframe_count, + subframe_sample_frames, + encode_sample_frames, + encode_subframe_sample_frames: packet_layout.subframe_sample_frames, + max_payload_bytes: opus_profile.max_bytes_per_frame_ms(profile.frame_time_ms()), + encoder, + }) + } + + pub const fn profile(&self) -> Profile { + self.profile + } + + pub const fn channels(&self) -> u8 { + self.channels + } + + pub const fn sample_frames(&self) -> usize { + self.sample_frames + } + + pub const fn subframe_count(&self) -> usize { + self.subframe_count + } + + pub const fn subframe_sample_frames(&self) -> usize { + self.subframe_sample_frames + } + + pub const fn max_payload_bytes(&self) -> usize { + self.max_payload_bytes + } + + pub fn encode_frame(&mut self, frame: &RawAudioFrame) -> Result { + self.validate_frame_shape(frame)?; + if self.subframe_count > 1 { + return self.encode_multi_subframe_packet(frame); + } + + let resampled; + let input = if self.encode_sample_frames == self.sample_frames { + &frame.samples + } else { + resampled = resample_interleaved_linear( + &frame.samples, + self.sample_frames, + self.encode_sample_frames, + usize::from(self.channels), + ); + &resampled + }; + let mut encoded = vec![0u8; self.max_payload_bytes]; + let written = self + .encoder + .encode(input, self.encode_sample_frames, &mut encoded) + .map_err(|err| OpusCodecError::Codec(err.to_string()))?; + encoded.truncate(written); + Ok(Frame::new(CodecKind::Opus, encoded)) + } + + fn encode_multi_subframe_packet( + &mut self, + frame: &RawAudioFrame, + ) -> Result { + let channels = usize::from(self.channels); + let budgets = self.subframe_payload_budgets()?; + let mut subpackets = Vec::with_capacity(self.subframe_count); + + for (subframe_index, payload_budget) in budgets.into_iter().enumerate() { + let start = subframe_index * self.subframe_sample_frames * channels; + let end = start + self.subframe_sample_frames * channels; + let resampled; + let input = if self.encode_subframe_sample_frames == self.subframe_sample_frames { + &frame.samples[start..end] + } else { + resampled = resample_interleaved_linear( + &frame.samples[start..end], + self.subframe_sample_frames, + self.encode_subframe_sample_frames, + channels, + ); + &resampled + }; + let mut encoded = vec![0u8; payload_budget + 1]; + let written = self + .encoder + .encode(input, self.encode_subframe_sample_frames, &mut encoded) + .map_err(|err| OpusCodecError::Codec(err.to_string()))?; + encoded.truncate(written); + if encoded.first().is_none_or(|toc| toc & 0x03 != 0) { + return Err(OpusCodecError::UnsupportedSubpacketLayout); + } + subpackets.push(encoded); + } + + let toc = (subpackets[0][0] & !0x03) | 0x03; + let mut payload = Vec::with_capacity(self.max_payload_bytes); + payload.push(toc); + payload.push(0x80 | (self.subframe_count as u8)); + for subpacket in subpackets.iter().take(self.subframe_count - 1) { + push_subframe_payload_len(&mut payload, subpacket.len() - 1)?; + } + for subpacket in subpackets { + payload.extend_from_slice(&subpacket[1..]); + } + + debug_assert!(payload.len() <= self.max_payload_bytes); + Ok(Frame::new(CodecKind::Opus, payload)) + } + + fn subframe_payload_budgets(&self) -> Result, OpusCodecError> { + let header_bytes = 2 + self.subframe_count - 1; + let payload_budget = self + .max_payload_bytes + .checked_sub(header_bytes) + .ok_or(OpusCodecError::UnsupportedSubpacketLayout)?; + let base = payload_budget / self.subframe_count; + let extra = payload_budget % self.subframe_count; + Ok((0..self.subframe_count) + .map(|index| base + usize::from(index < extra)) + .collect()) + } + + fn validate_frame_shape(&self, frame: &RawAudioFrame) -> Result<(), OpusCodecError> { + if frame.channels != self.channels { + return Err(OpusCodecError::ChannelMismatch { + expected: self.channels, + actual: frame.channels, + }); + } + if frame.sample_frames() != self.sample_frames { + return Err(OpusCodecError::SampleFrameMismatch { + expected: self.sample_frames, + actual: frame.sample_frames(), + }); + } + Ok(()) + } +} + +pub struct OpusDecoderState { + profile: Profile, + channels: u8, + sample_frames: usize, + subframe_count: usize, + subframe_sample_frames: usize, + decoder: OpusDecoder, +} + +impl OpusDecoderState { + pub fn new(profile: Profile) -> Result { + let opus_profile = match profile.audio_codec() { + AudioCodec::Opus(profile) => profile, + AudioCodec::Codec2(_) => return Err(OpusCodecError::NonOpusProfile(profile)), + }; + let channels = opus_profile.channels(); + let sample_rate = opus_profile.sample_rate(); + let sample_frames = profile.sample_frames_per_packet(); + let packet_layout = PacketLayout::new(sample_rate, sample_frames)?; + let decoder = OpusDecoder::new(sample_rate as i32, usize::from(channels)) + .map_err(|err| OpusCodecError::Codec(err.to_string()))?; + + Ok(Self { + profile, + channels, + sample_frames, + subframe_count: packet_layout.subframe_count, + subframe_sample_frames: packet_layout.subframe_sample_frames, + decoder, + }) + } + + pub const fn profile(&self) -> Profile { + self.profile + } + + pub const fn subframe_count(&self) -> usize { + self.subframe_count + } + + pub const fn subframe_sample_frames(&self) -> usize { + self.subframe_sample_frames + } + + pub fn decode_frame(&mut self, frame: &Frame) -> Result { + if frame.codec != CodecKind::Opus { + return Err(OpusCodecError::InvalidFrameCodec(frame.codec)); + } + if self.subframe_count > 1 && frame.payload.first().is_some_and(|toc| toc & 0x03 == 0x03) { + return self.decode_multi_subframe_packet(frame); + } + + self.decode_direct_packet(frame) + } + + fn decode_direct_packet(&mut self, frame: &Frame) -> Result { + let mut samples = vec![0.0f32; self.sample_frames * usize::from(self.channels)]; + let decoded = self + .decoder + .decode(&frame.payload, self.sample_frames, &mut samples) + .map_err(|err| OpusCodecError::Codec(err.to_string()))?; + samples.truncate(decoded * usize::from(self.channels)); + Ok(RawAudioFrame::new(self.channels, samples)?) + } + + fn decode_multi_subframe_packet( + &mut self, + frame: &Frame, + ) -> Result { + let subpayloads = parse_code3_subframe_payloads(&frame.payload, self.subframe_count)?; + let channels = usize::from(self.channels); + let mut samples = vec![0.0f32; self.sample_frames * channels]; + let toc = frame.payload[0] & !0x03; + + for (index, subpayload) in subpayloads.iter().enumerate() { + let start = index * self.subframe_sample_frames * channels; + let end = start + self.subframe_sample_frames * channels; + let mut subpacket = Vec::with_capacity(subpayload.len() + 1); + subpacket.push(toc); + subpacket.extend_from_slice(subpayload); + self.decoder + .decode( + &subpacket, + self.subframe_sample_frames, + &mut samples[start..end], + ) + .map_err(|err| OpusCodecError::Codec(err.to_string()))?; + } + + Ok(RawAudioFrame::new(self.channels, samples)?) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PacketLayout { + subframe_count: usize, + subframe_sample_frames: usize, +} + +impl PacketLayout { + fn new(sample_rate_hz: u32, sample_frames: usize) -> Result { + if supports_direct_frame(sample_rate_hz, sample_frames) { + return Ok(Self { + subframe_count: 1, + subframe_sample_frames: sample_frames, + }); + } + + let subframe_sample_frames = (sample_rate_hz as usize) / 50; + if sample_frames != 0 + && subframe_sample_frames != 0 + && sample_frames % subframe_sample_frames == 0 + && supports_direct_frame(sample_rate_hz, subframe_sample_frames) + { + return Ok(Self { + subframe_count: sample_frames / subframe_sample_frames, + subframe_sample_frames, + }); + } + + Err(OpusCodecError::UnsupportedFrameDuration { + sample_rate_hz, + sample_frames, + }) + } +} + +fn opus_application(application: OpusApplication) -> Application { + match application { + OpusApplication::Voip => Application::Voip, + OpusApplication::Audio => Application::Audio, + } +} + +fn encode_sample_rate(profile: OpusProfile) -> u32 { + match profile { + // Python LXST uses libopus with an output byte ceiling but no fixed + // bitrate or bandwidth CTLs. At Medium's 8 kbps ceiling, libopus is + // free to pick lower voice bandwidth; opus-rs otherwise forces + // superwideband/hybrid from the 24 kHz API rate, which is poor for + // speech at this budget. + OpusProfile::VoiceMedium => 16_000, + _ => profile.sample_rate(), + } +} + +fn scale_sample_frames( + sample_frames: usize, + source_sample_rate: u32, + encode_sample_rate: u32, +) -> Result { + let numerator = sample_frames + .checked_mul(encode_sample_rate as usize) + .ok_or(OpusCodecError::UnsupportedFrameDuration { + sample_rate_hz: encode_sample_rate, + sample_frames, + })?; + let denominator = source_sample_rate as usize; + if numerator % denominator != 0 { + return Err(OpusCodecError::UnsupportedFrameDuration { + sample_rate_hz: encode_sample_rate, + sample_frames, + }); + } + Ok(numerator / denominator) +} + +fn supports_direct_frame(sample_rate_hz: u32, sample_frames: usize) -> bool { + sample_frames != 0 && (sample_rate_hz as usize) % sample_frames == 0 +} + +fn resample_interleaved_linear( + input: &[f32], + input_frames: usize, + output_frames: usize, + channels: usize, +) -> Vec { + if input_frames == output_frames { + return input.to_vec(); + } + let mut output = vec![0.0f32; output_frames * channels]; + if input_frames == 0 || output_frames == 0 || channels == 0 { + return output; + } + if input_frames == 1 { + for frame in 0..output_frames { + let out = frame * channels; + output[out..out + channels].copy_from_slice(&input[..channels]); + } + return output; + } + + let scale = input_frames as f64 / output_frames as f64; + let max_input_index = input_frames - 1; + for out_frame in 0..output_frames { + let src = ((out_frame as f64 + 0.5) * scale - 0.5).clamp(0.0, max_input_index as f64); + let left = src.floor() as usize; + let right = (left + 1).min(max_input_index); + let fraction = (src - left as f64) as f32; + let out = out_frame * channels; + let left_offset = left * channels; + let right_offset = right * channels; + for channel in 0..channels { + let a = input[left_offset + channel]; + let b = input[right_offset + channel]; + output[out + channel] = a + (b - a) * fraction; + } + } + output +} + +fn push_subframe_payload_len(output: &mut Vec, len: usize) -> Result<(), OpusCodecError> { + if len < 252 { + output.push(len as u8); + Ok(()) + } else if len <= 1275 { + let first = 252 + (len % 4); + output.push(first as u8); + output.push(((len - first) / 4) as u8); + Ok(()) + } else { + Err(OpusCodecError::UnsupportedSubframePayloadLength(len)) + } +} + +fn parse_code3_subframe_payloads( + packet: &[u8], + expected_count: usize, +) -> Result, OpusCodecError> { + if packet.len() < 2 { + return Err(OpusCodecError::MalformedPacket( + "code 3 packet is too short", + )); + } + + let count_byte = packet[1]; + let frame_count = usize::from(count_byte & 0x3F); + if frame_count != expected_count { + return Err(OpusCodecError::MalformedPacket( + "code 3 frame count does not match the active profile", + )); + } + if frame_count == 0 { + return Err(OpusCodecError::MalformedPacket( + "code 3 frame count is zero", + )); + } + + let vbr = count_byte & 0x80 != 0; + let padding = count_byte & 0x40 != 0; + let mut cursor = 2; + let mut payload_end = packet.len(); + + if padding { + let mut pad_len = 0usize; + loop { + if cursor >= packet.len() { + return Err(OpusCodecError::MalformedPacket("padding exceeds packet")); + } + let byte = usize::from(packet[cursor]); + cursor += 1; + if byte == 255 { + pad_len += 254; + } else { + pad_len += byte; + break; + } + } + payload_end = packet + .len() + .checked_sub(pad_len) + .ok_or(OpusCodecError::MalformedPacket("padding exceeds packet"))?; + if cursor > payload_end { + return Err(OpusCodecError::MalformedPacket("padding exceeds payload")); + } + } + + if vbr { + let mut lengths = Vec::with_capacity(frame_count); + for _ in 0..frame_count - 1 { + let (len, consumed) = read_subframe_payload_len(&packet[cursor..payload_end])?; + cursor += consumed; + lengths.push(len); + } + + let declared_payload_bytes = lengths.iter().sum::(); + let remaining = payload_end + .checked_sub(cursor) + .ok_or(OpusCodecError::MalformedPacket( + "payload cursor exceeds packet", + ))?; + if declared_payload_bytes > remaining { + return Err(OpusCodecError::MalformedPacket( + "declared frame lengths exceed packet payload", + )); + } + lengths.push(remaining - declared_payload_bytes); + + let mut payloads = Vec::with_capacity(frame_count); + let mut payload_cursor = cursor; + for len in lengths { + let next = payload_cursor + len; + payloads.push(&packet[payload_cursor..next]); + payload_cursor = next; + } + Ok(payloads) + } else { + let compressed = &packet[cursor..payload_end]; + if compressed.len() % frame_count != 0 { + return Err(OpusCodecError::MalformedPacket( + "CBR code 3 payload is not evenly divisible", + )); + } + let frame_len = compressed.len() / frame_count; + Ok(compressed.chunks(frame_len).collect()) + } +} + +fn read_subframe_payload_len(input: &[u8]) -> Result<(usize, usize), OpusCodecError> { + let first = *input + .first() + .ok_or(OpusCodecError::MalformedPacket("missing frame length"))?; + if first < 252 { + Ok((usize::from(first), 1)) + } else { + let second = *input + .get(1) + .ok_or(OpusCodecError::MalformedPacket("truncated frame length"))?; + Ok((usize::from(first) + 4 * usize::from(second), 2)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SyntheticSourceKind; + + fn source_for(profile: Profile) -> crate::SyntheticSource { + crate::SyntheticSource::new( + profile.channels(), + profile.sample_rate_hz(), + profile.sample_frames_per_packet(), + SyntheticSourceKind::Sine { + frequency_hz: 440.0, + amplitude: 0.25, + }, + ) + .unwrap() + } + + #[test] + fn opus_encoder_rejects_codec2_profiles() { + assert!(matches!( + OpusEncoderState::new(Profile::BandwidthLow), + Err(OpusCodecError::NonOpusProfile(Profile::BandwidthLow)) + )); + } + + #[test] + fn opus_profile_encoder_caps_payload_to_python_budget() { + let profile = Profile::LatencyLow; + let mut encoder = OpusEncoderState::new(profile).unwrap(); + let frame = source_for(profile).next_raw_frame().unwrap(); + + let encoded = encoder.encode_frame(&frame).unwrap(); + assert_eq!(encoded.codec, CodecKind::Opus); + assert!(encoded.payload.len() <= profile.opus_payload_ceiling_bytes().unwrap()); + } + + #[test] + fn opus_roundtrip_decodes_profile_shaped_pcm() { + let profile = Profile::LatencyLow; + let mut source = source_for(profile); + let frame = source.next_raw_frame().unwrap(); + let mut encoder = OpusEncoderState::new(profile).unwrap(); + let mut decoder = OpusDecoderState::new(profile).unwrap(); + + let encoded = encoder.encode_frame(&frame).unwrap(); + let decoded = decoder.decode_frame(&encoded).unwrap(); + + assert_eq!(decoded.channels, profile.channels()); + assert_eq!(decoded.sample_frames(), profile.sample_frames_per_packet()); + assert_eq!(decoder.profile(), profile); + } + + #[test] + fn opus_quality_profiles_encode_sixty_ms_as_three_subframes() { + for profile in [ + Profile::QualityMedium, + Profile::QualityHigh, + Profile::QualityMax, + ] { + let mut source = source_for(profile); + let frame = source.next_raw_frame().unwrap(); + let mut encoder = OpusEncoderState::new(profile).unwrap(); + let mut decoder = OpusDecoderState::new(profile).unwrap(); + + assert_eq!(encoder.subframe_count(), 3); + assert_eq!(decoder.subframe_count(), 3); + assert_eq!( + encoder.subframe_sample_frames() * 3, + encoder.sample_frames() + ); + + let encoded = encoder.encode_frame(&frame).unwrap(); + assert_eq!(encoded.codec, CodecKind::Opus); + assert_eq!(encoded.payload[0] & 0x03, 0x03); + assert_eq!(encoded.payload[1] & 0x3F, 3); + assert_ne!(encoded.payload[1] & 0x80, 0); + assert!(encoded.payload.len() <= profile.opus_payload_ceiling_bytes().unwrap()); + + let decoded = decoder.decode_frame(&encoded).unwrap(); + assert_eq!(decoded.channels, profile.channels()); + assert_eq!(decoded.sample_frames(), profile.sample_frames_per_packet()); + } + } + + #[test] + fn opus_medium_uses_wideband_silk_at_low_bitrate() { + let profile = Profile::QualityMedium; + let mut source = source_for(profile); + let frame = source.next_raw_frame().unwrap(); + let mut encoder = OpusEncoderState::new(profile).unwrap(); + + let encoded = encoder.encode_frame(&frame).unwrap(); + + assert_eq!(encoded.payload[0] & !0x03, 0x48); + assert!(encoded.payload.len() <= profile.opus_payload_ceiling_bytes().unwrap()); + } +} diff --git a/crates/lxst-core/src/profile.rs b/crates/lxst-core/src/profile.rs new file mode 100644 index 0000000..2d48dde --- /dev/null +++ b/crates/lxst-core/src/profile.rs @@ -0,0 +1,356 @@ +use crate::wire::{Codec2Mode, CodecKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u32)] +pub enum SignallingStatus { + Busy = 0x00, + Rejected = 0x01, + Calling = 0x02, + Available = 0x03, + Ringing = 0x04, + Connecting = 0x05, + Established = 0x06, +} + +impl SignallingStatus { + pub const AUTO_STATUS_CODES: [Self; 5] = [ + Self::Calling, + Self::Available, + Self::Ringing, + Self::Connecting, + Self::Established, + ]; + + pub const fn wire_value(self) -> u32 { + self as u32 + } + + pub const fn from_wire(value: u32) -> Option { + match value { + 0x00 => Some(Self::Busy), + 0x01 => Some(Self::Rejected), + 0x02 => Some(Self::Calling), + 0x03 => Some(Self::Available), + 0x04 => Some(Self::Ringing), + 0x05 => Some(Self::Connecting), + 0x06 => Some(Self::Established), + _ => None, + } + } + + pub fn is_auto_status(self) -> bool { + Self::AUTO_STATUS_CODES.contains(&self) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u32)] +pub enum Profile { + BandwidthUltraLow = 0x10, + BandwidthVeryLow = 0x20, + BandwidthLow = 0x30, + QualityMedium = 0x40, + QualityHigh = 0x50, + QualityMax = 0x60, + LatencyUltraLow = 0x70, + LatencyLow = 0x80, +} + +impl Profile { + pub const DEFAULT: Self = Self::QualityMedium; + + pub const ORDER: [Self; 8] = [ + Self::BandwidthUltraLow, + Self::BandwidthVeryLow, + Self::BandwidthLow, + Self::QualityMedium, + Self::QualityHigh, + Self::QualityMax, + Self::LatencyLow, + Self::LatencyUltraLow, + ]; + + pub const fn wire_value(self) -> u32 { + self as u32 + } + + pub const fn from_wire(value: u32) -> Option { + match value { + 0x10 => Some(Self::BandwidthUltraLow), + 0x20 => Some(Self::BandwidthVeryLow), + 0x30 => Some(Self::BandwidthLow), + 0x40 => Some(Self::QualityMedium), + 0x50 => Some(Self::QualityHigh), + 0x60 => Some(Self::QualityMax), + 0x70 => Some(Self::LatencyUltraLow), + 0x80 => Some(Self::LatencyLow), + _ => None, + } + } + + pub const fn name(self) -> &'static str { + match self { + Self::BandwidthUltraLow => "Ultra Low Bandwidth", + Self::BandwidthVeryLow => "Very Low Bandwidth", + Self::BandwidthLow => "Low Bandwidth", + Self::QualityMedium => "Medium Quality", + Self::QualityHigh => "High Quality", + Self::QualityMax => "Super High Quality", + Self::LatencyLow => "Low Latency", + Self::LatencyUltraLow => "Ultra Low Latency", + } + } + + pub const fn abbreviation(self) -> &'static str { + match self { + Self::BandwidthUltraLow => "ULBW", + Self::BandwidthVeryLow => "VLBW", + Self::BandwidthLow => "LBW", + Self::QualityMedium => "MQ", + Self::QualityHigh => "HQ", + Self::QualityMax => "SHQ", + Self::LatencyLow => "LL", + Self::LatencyUltraLow => "ULL", + } + } + + pub const fn frame_time_ms(self) -> u16 { + match self { + Self::BandwidthUltraLow => 400, + Self::BandwidthVeryLow => 320, + Self::BandwidthLow => 200, + Self::QualityMedium => 60, + Self::QualityHigh => 60, + Self::QualityMax => 60, + Self::LatencyLow => 20, + Self::LatencyUltraLow => 10, + } + } + + pub const fn audio_codec(self) -> AudioCodec { + match self { + Self::BandwidthUltraLow => AudioCodec::Codec2(Codec2Mode::Mode700C), + Self::BandwidthVeryLow => AudioCodec::Codec2(Codec2Mode::Mode1600), + Self::BandwidthLow => AudioCodec::Codec2(Codec2Mode::Mode3200), + Self::QualityMedium => AudioCodec::Opus(OpusProfile::VoiceMedium), + Self::QualityHigh => AudioCodec::Opus(OpusProfile::VoiceHigh), + Self::QualityMax => AudioCodec::Opus(OpusProfile::VoiceMax), + Self::LatencyLow => AudioCodec::Opus(OpusProfile::VoiceMedium), + Self::LatencyUltraLow => AudioCodec::Opus(OpusProfile::VoiceMedium), + } + } + + pub const fn channels(self) -> u8 { + self.audio_codec().channels() + } + + pub const fn sample_rate_hz(self) -> u32 { + self.audio_codec().sample_rate_hz() + } + + pub const fn sample_frames_per_packet(self) -> usize { + ((self.sample_rate_hz() as usize) * (self.frame_time_ms() as usize)) / 1000 + } + + pub const fn opus_payload_ceiling_bytes(self) -> Option { + match self.audio_codec() { + AudioCodec::Opus(profile) => Some(profile.max_bytes_per_frame_ms(self.frame_time_ms())), + AudioCodec::Codec2(_) => None, + } + } + + pub fn next(self) -> Self { + let index = Self::ORDER + .iter() + .position(|candidate| *candidate == self) + .unwrap_or(0); + Self::ORDER[(index + 1) % Self::ORDER.len()] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AudioCodec { + Opus(OpusProfile), + Codec2(Codec2Mode), +} + +impl AudioCodec { + pub const fn codec_kind(self) -> CodecKind { + match self { + Self::Opus(_) => CodecKind::Opus, + Self::Codec2(_) => CodecKind::Codec2, + } + } + + pub const fn channels(self) -> u8 { + match self { + Self::Opus(profile) => profile.channels(), + Self::Codec2(_) => 1, + } + } + + pub const fn sample_rate_hz(self) -> u32 { + match self { + Self::Opus(profile) => profile.sample_rate(), + Self::Codec2(_) => 8_000, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum OpusProfile { + VoiceLow = 0x00, + VoiceMedium = 0x01, + VoiceHigh = 0x02, + VoiceMax = 0x03, + AudioMin = 0x04, + AudioLow = 0x05, + AudioMedium = 0x06, + AudioHigh = 0x07, + AudioMax = 0x08, +} + +impl OpusProfile { + pub const VALID_FRAME_MS: [f32; 6] = [2.5, 5.0, 10.0, 20.0, 40.0, 60.0]; + pub const FRAME_QUANTA_MS: f32 = 2.5; + pub const FRAME_MAX_MS: f32 = 60.0; + + pub const fn channels(self) -> u8 { + match self { + Self::VoiceLow | Self::VoiceMedium | Self::VoiceHigh => 1, + Self::VoiceMax => 2, + Self::AudioMin | Self::AudioLow => 1, + Self::AudioMedium | Self::AudioHigh | Self::AudioMax => 2, + } + } + + pub const fn sample_rate(self) -> u32 { + match self { + Self::VoiceLow => 8_000, + Self::VoiceMedium => 24_000, + Self::VoiceHigh | Self::VoiceMax => 48_000, + Self::AudioMin => 8_000, + Self::AudioLow => 12_000, + Self::AudioMedium => 24_000, + Self::AudioHigh | Self::AudioMax => 48_000, + } + } + + pub const fn application(self) -> OpusApplication { + match self { + Self::VoiceLow | Self::VoiceMedium | Self::VoiceHigh | Self::VoiceMax => { + OpusApplication::Voip + } + Self::AudioMin + | Self::AudioLow + | Self::AudioMedium + | Self::AudioHigh + | Self::AudioMax => OpusApplication::Audio, + } + } + + pub const fn bitrate_ceiling(self) -> u32 { + match self { + Self::VoiceLow => 6_000, + Self::VoiceMedium => 8_000, + Self::VoiceHigh => 16_000, + Self::VoiceMax => 32_000, + Self::AudioMin => 8_000, + Self::AudioLow => 14_000, + Self::AudioMedium => 28_000, + Self::AudioHigh => 56_000, + Self::AudioMax => 128_000, + } + } + + pub const fn max_bytes_per_frame_ms(self, frame_duration_ms: u16) -> usize { + let numerator = (self.bitrate_ceiling() as usize) * (frame_duration_ms as usize); + numerator.div_ceil(8_000) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum OpusApplication { + Voip, + Audio, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_order_matches_python_next_profile_order() { + assert_eq!(Profile::QualityMax.next(), Profile::LatencyLow); + assert_eq!(Profile::LatencyLow.next(), Profile::LatencyUltraLow); + assert_eq!(Profile::LatencyUltraLow.next(), Profile::BandwidthUltraLow); + } + + #[test] + fn telephony_profile_mapping_matches_python_reference() { + assert_eq!( + Profile::BandwidthUltraLow.audio_codec(), + AudioCodec::Codec2(Codec2Mode::Mode700C) + ); + assert_eq!( + Profile::BandwidthVeryLow.audio_codec(), + AudioCodec::Codec2(Codec2Mode::Mode1600) + ); + assert_eq!( + Profile::BandwidthLow.audio_codec(), + AudioCodec::Codec2(Codec2Mode::Mode3200) + ); + assert_eq!( + Profile::LatencyUltraLow.audio_codec(), + AudioCodec::Opus(OpusProfile::VoiceMedium) + ); + assert_eq!(Profile::LatencyUltraLow.frame_time_ms(), 10); + assert_eq!(Profile::LatencyLow.frame_time_ms(), 20); + } + + #[test] + fn telephony_profile_audio_budgets_match_python_tables() { + assert_eq!(Profile::BandwidthUltraLow.channels(), 1); + assert_eq!(Profile::BandwidthUltraLow.sample_rate_hz(), 8_000); + assert_eq!(Profile::BandwidthUltraLow.sample_frames_per_packet(), 3_200); + assert_eq!( + Profile::BandwidthUltraLow.opus_payload_ceiling_bytes(), + None + ); + + assert_eq!(Profile::QualityMedium.channels(), 1); + assert_eq!(Profile::QualityMedium.sample_rate_hz(), 24_000); + assert_eq!(Profile::QualityMedium.sample_frames_per_packet(), 1_440); + assert_eq!( + Profile::QualityMedium.opus_payload_ceiling_bytes(), + Some(60) + ); + + assert_eq!(Profile::QualityHigh.channels(), 1); + assert_eq!(Profile::QualityHigh.sample_rate_hz(), 48_000); + assert_eq!(Profile::QualityHigh.sample_frames_per_packet(), 2_880); + assert_eq!(Profile::QualityHigh.opus_payload_ceiling_bytes(), Some(120)); + + assert_eq!(Profile::QualityMax.channels(), 2); + assert_eq!(Profile::QualityMax.sample_rate_hz(), 48_000); + assert_eq!(Profile::QualityMax.sample_frames_per_packet(), 2_880); + assert_eq!(Profile::QualityMax.opus_payload_ceiling_bytes(), Some(240)); + + assert_eq!(Profile::LatencyLow.sample_frames_per_packet(), 480); + assert_eq!(Profile::LatencyLow.opus_payload_ceiling_bytes(), Some(20)); + assert_eq!(Profile::LatencyUltraLow.sample_frames_per_packet(), 240); + assert_eq!( + Profile::LatencyUltraLow.opus_payload_ceiling_bytes(), + Some(10) + ); + } + + #[test] + fn opus_max_bytes_per_frame_matches_python_formula() { + assert_eq!(OpusProfile::VoiceMedium.max_bytes_per_frame_ms(60), 60); + assert_eq!(OpusProfile::VoiceHigh.max_bytes_per_frame_ms(60), 120); + assert_eq!(OpusProfile::VoiceMax.max_bytes_per_frame_ms(60), 240); + assert_eq!(OpusProfile::AudioMax.max_bytes_per_frame_ms(60), 960); + } +} diff --git a/crates/lxst-core/src/raw.rs b/crates/lxst-core/src/raw.rs new file mode 100644 index 0000000..7395980 --- /dev/null +++ b/crates/lxst-core/src/raw.rs @@ -0,0 +1,204 @@ +use half::f16; + +use crate::{CodecKind, Error, Frame, RawBitDepth, RawFrameHeader}; + +#[derive(Debug, Clone, PartialEq)] +pub struct RawAudioFrame { + pub channels: u8, + pub samples: Vec, +} + +impl RawAudioFrame { + pub fn new(channels: u8, samples: impl Into>) -> Result { + let samples = samples.into(); + validate_sample_count(channels, samples.len())?; + Ok(Self { channels, samples }) + } + + pub fn sample_frames(&self) -> usize { + self.samples.len() / usize::from(self.channels) + } + + pub fn from_frame(frame: &Frame) -> Result { + if frame.codec != CodecKind::Raw { + return Err(Error::InvalidRawFrameCodec(frame.codec)); + } + + Self::from_payload(&frame.payload) + } + + pub fn to_frame(&self, bit_depth: RawBitDepth) -> Result { + Ok(Frame::new(CodecKind::Raw, self.to_payload(bit_depth)?)) + } + + pub fn from_payload(payload: &[u8]) -> Result { + let Some((&header_byte, sample_bytes)) = payload.split_first() else { + return Err(Error::EmptyRawPayload); + }; + + let header = RawFrameHeader::parse(header_byte)?; + let samples = decode_samples(sample_bytes, header.bit_depth)?; + validate_sample_count(header.channels, samples.len())?; + + Ok(Self { + channels: header.channels, + samples, + }) + } + + pub fn to_payload(&self, bit_depth: RawBitDepth) -> Result, Error> { + validate_sample_count(self.channels, self.samples.len())?; + let header = RawFrameHeader::new(self.channels, bit_depth)?; + let mut out = Vec::with_capacity(1 + self.samples.len() * bit_depth.bytes_per_sample()); + out.push(header.encode()); + encode_samples(&self.samples, bit_depth, &mut out); + Ok(out) + } +} + +fn validate_sample_count(channels: u8, samples: usize) -> Result<(), Error> { + RawFrameHeader::new(channels, RawBitDepth::Float16)?; + if samples % usize::from(channels) != 0 { + Err(Error::InvalidRawSampleCount { samples, channels }) + } else { + Ok(()) + } +} + +fn decode_samples(bytes: &[u8], bit_depth: RawBitDepth) -> Result, Error> { + let bytes_per_sample = bit_depth.bytes_per_sample(); + if bytes.len() % bytes_per_sample != 0 { + return Err(Error::InvalidRawSampleBytes { bytes_per_sample }); + } + + let mut samples = Vec::with_capacity(bytes.len() / bytes_per_sample); + match bit_depth { + RawBitDepth::Float16 => { + for chunk in bytes.chunks_exact(2) { + samples.push(f16::from_le_bytes([chunk[0], chunk[1]]).to_f32()); + } + } + RawBitDepth::Float32 => { + for chunk in bytes.chunks_exact(4) { + samples.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } + } + RawBitDepth::Float64 => { + for chunk in bytes.chunks_exact(8) { + samples.push(f64::from_le_bytes([ + chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], chunk[7], + ]) as f32); + } + } + RawBitDepth::Float128 => { + for chunk in bytes.chunks_exact(16) { + samples.push(decode_float128_lossy(chunk)); + } + } + } + + Ok(samples) +} + +fn encode_samples(samples: &[f32], bit_depth: RawBitDepth, out: &mut Vec) { + match bit_depth { + RawBitDepth::Float16 => { + for sample in samples { + out.extend_from_slice(&f16::from_f32(*sample).to_le_bytes()); + } + } + RawBitDepth::Float32 => { + for sample in samples { + out.extend_from_slice(&sample.to_le_bytes()); + } + } + RawBitDepth::Float64 => { + for sample in samples { + out.extend_from_slice(&f64::from(*sample).to_le_bytes()); + } + } + RawBitDepth::Float128 => { + for sample in samples { + encode_float128_from_f32(*sample, out); + } + } + } +} + +fn decode_float128_lossy(bytes: &[u8]) -> f32 { + // Python/Numpy names this dtype "float128", but on common little-endian + // platforms it may be backed by an 80-bit extended value padded to 16 bytes. + // We preserve finite zero exactly and otherwise use the leading f64 lane as + // a conservative lossy fallback until a platform-specific long-double codec + // is introduced. + if bytes.iter().all(|byte| *byte == 0) { + 0.0 + } else { + f64::from_le_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]) as f32 + } +} + +fn encode_float128_from_f32(sample: f32, out: &mut Vec) { + out.extend_from_slice(&f64::from(sample).to_le_bytes()); + out.extend_from_slice(&[0u8; 8]); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_audio_frame_encodes_python_default_float16_payload() { + let raw = RawAudioFrame::new(2, vec![0.0, 0.5, -0.25, 1.0]).unwrap(); + let payload = raw.to_payload(RawBitDepth::Float16).unwrap(); + assert_eq!( + payload, + vec![0x01, 0x00, 0x00, 0x00, 0x38, 0x00, 0xB4, 0x00, 0x3C] + ); + assert_eq!(RawAudioFrame::from_payload(&payload).unwrap(), raw); + } + + #[test] + fn raw_audio_frame_encodes_float32_and_float64() { + let raw = RawAudioFrame::new(1, vec![0.25, -1.5]).unwrap(); + + let f32_payload = raw.to_payload(RawBitDepth::Float32).unwrap(); + assert_eq!(f32_payload[0], 0x40); + assert_eq!(RawAudioFrame::from_payload(&f32_payload).unwrap(), raw); + + let f64_payload = raw.to_payload(RawBitDepth::Float64).unwrap(); + assert_eq!(f64_payload[0], 0x80); + assert_eq!(RawAudioFrame::from_payload(&f64_payload).unwrap(), raw); + } + + #[test] + fn raw_audio_frame_rejects_misaligned_payloads() { + assert_eq!( + RawAudioFrame::from_payload(&[]), + Err(Error::EmptyRawPayload) + ); + assert_eq!( + RawAudioFrame::from_payload(&[0x40, 0x00]), + Err(Error::InvalidRawSampleBytes { + bytes_per_sample: 4, + }) + ); + assert_eq!( + RawAudioFrame::new(2, vec![0.0]), + Err(Error::InvalidRawSampleCount { + samples: 1, + channels: 2, + }) + ); + } + + #[test] + fn raw_audio_frame_converts_to_and_from_lxst_frame() { + let raw = RawAudioFrame::new(1, vec![0.0, 1.0]).unwrap(); + let frame = raw.to_frame(RawBitDepth::Float16).unwrap(); + assert_eq!(frame.codec, CodecKind::Raw); + assert_eq!(RawAudioFrame::from_frame(&frame).unwrap(), raw); + } +} diff --git a/crates/lxst-core/src/stream.rs b/crates/lxst-core/src/stream.rs new file mode 100644 index 0000000..7bb5039 --- /dev/null +++ b/crates/lxst-core/src/stream.rs @@ -0,0 +1,339 @@ +use std::collections::VecDeque; + +use thiserror::Error; + +use crate::{CodecKind, Frame, LxstPacket}; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum StreamError { + #[error("frame packetizer batch size must be greater than zero")] + InvalidBatchSize, + #[error("jitter buffer capacity must be greater than zero")] + InvalidJitterCapacity, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FramePacketizer { + frames_per_packet: usize, +} + +impl FramePacketizer { + pub const PYTHON_COMPATIBLE: Self = Self { + frames_per_packet: 1, + }; + + pub fn new(frames_per_packet: usize) -> Result { + if frames_per_packet == 0 { + Err(StreamError::InvalidBatchSize) + } else { + Ok(Self { frames_per_packet }) + } + } + + pub const fn frames_per_packet(self) -> usize { + self.frames_per_packet + } + + pub fn packetize_one(self, frame: Frame) -> LxstPacket { + LxstPacket::frame(frame) + } + + pub fn packetize(self, frames: impl IntoIterator) -> Vec { + let mut packets = Vec::new(); + let mut batch = Vec::with_capacity(self.frames_per_packet); + + for frame in frames { + batch.push(frame); + if batch.len() == self.frames_per_packet { + packets.push(packet_from_batch(&mut batch)); + } + } + + if !batch.is_empty() { + packets.push(packet_from_batch(&mut batch)); + } + + packets + } +} + +fn packet_from_batch(batch: &mut Vec) -> LxstPacket { + if batch.len() == 1 { + LxstPacket::frame(batch.pop().expect("batch has one frame")) + } else { + LxstPacket { + signals: Vec::new(), + frames: std::mem::take(batch), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameStreamEvent { + CodecChanged { + from: Option, + to: CodecKind, + }, + Frame(Frame), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FrameStreamState { + current_codec: Option, +} + +impl FrameStreamState { + pub fn new() -> Self { + Self::default() + } + + pub const fn current_codec(&self) -> Option { + self.current_codec + } + + pub fn accept_packet(&mut self, packet: LxstPacket) -> Vec { + let mut events = Vec::new(); + + for frame in packet.frames { + if self.current_codec != Some(frame.codec) { + let from = self.current_codec; + self.current_codec = Some(frame.codec); + events.push(FrameStreamEvent::CodecChanged { + from, + to: frame.codec, + }); + } + events.push(FrameStreamEvent::Frame(frame)); + } + + events + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DropPolicy { + DropNewest, + DropOldest, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct JitterStats { + pub pushed: u64, + pub popped: u64, + pub dropped_oldest: u64, + pub dropped_newest: u64, + pub underruns: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JitterPush { + Accepted, + DroppedIncoming(T), + DroppedOldest(T), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JitterBuffer { + capacity: usize, + drop_policy: DropPolicy, + queue: VecDeque, + stats: JitterStats, +} + +impl JitterBuffer { + pub fn new(capacity: usize, drop_policy: DropPolicy) -> Result { + if capacity == 0 { + Err(StreamError::InvalidJitterCapacity) + } else { + Ok(Self { + capacity, + drop_policy, + queue: VecDeque::with_capacity(capacity), + stats: JitterStats::default(), + }) + } + } + + pub const fn capacity(&self) -> usize { + self.capacity + } + + pub const fn drop_policy(&self) -> DropPolicy { + self.drop_policy + } + + pub fn len(&self) -> usize { + self.queue.len() + } + + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } + + pub const fn stats(&self) -> JitterStats { + self.stats + } + + pub fn push(&mut self, item: T) -> JitterPush { + self.stats.pushed += 1; + if self.queue.len() < self.capacity { + self.queue.push_back(item); + return JitterPush::Accepted; + } + + match self.drop_policy { + DropPolicy::DropNewest => { + self.stats.dropped_newest += 1; + JitterPush::DroppedIncoming(item) + } + DropPolicy::DropOldest => { + self.stats.dropped_oldest += 1; + let dropped = self + .queue + .pop_front() + .expect("full jitter buffer has oldest item"); + self.queue.push_back(item); + JitterPush::DroppedOldest(dropped) + } + } + } + + pub fn pop(&mut self) -> Option { + let item = self.queue.pop_front(); + if item.is_some() { + self.stats.popped += 1; + } else { + self.stats.underruns += 1; + } + item + } + + pub fn clear(&mut self) { + self.queue.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn raw_frame(byte: u8) -> Frame { + Frame::new(CodecKind::Raw, [0x00, byte]) + } + + #[test] + fn python_compatible_packetizer_sends_one_frame_per_packet() { + let frames = vec![raw_frame(1), raw_frame(2)]; + let packets = FramePacketizer::PYTHON_COMPATIBLE.packetize(frames.clone()); + + assert_eq!(packets.len(), 2); + assert_eq!(packets[0].frames, vec![frames[0].clone()]); + assert_eq!(packets[1].frames, vec![frames[1].clone()]); + } + + #[test] + fn batched_packetizer_groups_frames_without_reordering() { + let frames = vec![raw_frame(1), raw_frame(2), raw_frame(3)]; + let packetizer = FramePacketizer::new(2).unwrap(); + let packets = packetizer.packetize(frames.clone()); + + assert_eq!(packetizer.frames_per_packet(), 2); + assert_eq!(packets.len(), 2); + assert_eq!( + packets[0].frames, + vec![frames[0].clone(), frames[1].clone()] + ); + assert_eq!(packets[1].frames, vec![frames[2].clone()]); + } + + #[test] + fn frame_stream_state_emits_codec_changes_before_frames() { + let mut state = FrameStreamState::new(); + let packet = LxstPacket { + signals: Vec::new(), + frames: vec![ + Frame::new(CodecKind::Raw, [0x00]), + Frame::new(CodecKind::Raw, [0x01]), + Frame::new(CodecKind::Opus, [0xF8]), + ], + }; + + let events = state.accept_packet(packet); + assert_eq!( + events, + vec![ + FrameStreamEvent::CodecChanged { + from: None, + to: CodecKind::Raw, + }, + FrameStreamEvent::Frame(Frame::new(CodecKind::Raw, [0x00])), + FrameStreamEvent::Frame(Frame::new(CodecKind::Raw, [0x01])), + FrameStreamEvent::CodecChanged { + from: Some(CodecKind::Raw), + to: CodecKind::Opus, + }, + FrameStreamEvent::Frame(Frame::new(CodecKind::Opus, [0xF8])), + ] + ); + assert_eq!(state.current_codec(), Some(CodecKind::Opus)); + } + + #[test] + fn jitter_buffer_drop_newest_keeps_existing_latency_window() { + let mut buffer = JitterBuffer::new(2, DropPolicy::DropNewest).unwrap(); + assert_eq!(buffer.push(1), JitterPush::Accepted); + assert_eq!(buffer.push(2), JitterPush::Accepted); + assert_eq!(buffer.push(3), JitterPush::DroppedIncoming(3)); + assert_eq!(buffer.pop(), Some(1)); + assert_eq!(buffer.pop(), Some(2)); + assert_eq!( + buffer.stats(), + JitterStats { + pushed: 3, + popped: 2, + dropped_oldest: 0, + dropped_newest: 1, + underruns: 0, + } + ); + } + + #[test] + fn jitter_buffer_drop_oldest_preserves_latest_audio() { + let mut buffer = JitterBuffer::new(2, DropPolicy::DropOldest).unwrap(); + assert_eq!(buffer.push(1), JitterPush::Accepted); + assert_eq!(buffer.push(2), JitterPush::Accepted); + assert_eq!(buffer.push(3), JitterPush::DroppedOldest(1)); + assert_eq!(buffer.pop(), Some(2)); + assert_eq!(buffer.pop(), Some(3)); + assert_eq!( + buffer.stats(), + JitterStats { + pushed: 3, + popped: 2, + dropped_oldest: 1, + dropped_newest: 0, + underruns: 0, + } + ); + } + + #[test] + fn jitter_buffer_tracks_playback_underruns() { + let mut buffer = JitterBuffer::new(2, DropPolicy::DropOldest).unwrap(); + assert_eq!(buffer.pop(), None); + assert_eq!(buffer.push(1), JitterPush::Accepted); + assert_eq!(buffer.pop(), Some(1)); + assert_eq!(buffer.pop(), None); + assert_eq!( + buffer.stats(), + JitterStats { + pushed: 1, + popped: 1, + dropped_oldest: 0, + dropped_newest: 0, + underruns: 2, + } + ); + } +} diff --git a/crates/lxst-core/src/synthetic.rs b/crates/lxst-core/src/synthetic.rs new file mode 100644 index 0000000..03bb994 --- /dev/null +++ b/crates/lxst-core/src/synthetic.rs @@ -0,0 +1,249 @@ +use thiserror::Error; + +use crate::{Error as WireError, RawAudioFrame, RawFrameHeader}; + +#[derive(Debug, Error, PartialEq)] +pub enum SyntheticError { + #[error("synthetic source sample rate must be greater than zero")] + InvalidSampleRate, + #[error("synthetic source frame sample count must be greater than zero")] + InvalidFrameSamples, + #[error("synthetic source parameter must be finite")] + NonFiniteParameter, + #[error("raw frame error: {0}")] + Raw(#[from] WireError), +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SyntheticSourceKind { + Silence, + Ramp { start: f32, step: f32 }, + Sine { frequency_hz: f32, amplitude: f32 }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SyntheticSource { + channels: u8, + sample_rate_hz: u32, + frame_samples: usize, + kind: SyntheticSourceKind, + cursor_samples: u64, +} + +impl SyntheticSource { + pub fn new( + channels: u8, + sample_rate_hz: u32, + frame_samples: usize, + kind: SyntheticSourceKind, + ) -> Result { + RawFrameHeader::new(channels, crate::RawBitDepth::Float16)?; + if sample_rate_hz == 0 { + return Err(SyntheticError::InvalidSampleRate); + } + if frame_samples == 0 { + return Err(SyntheticError::InvalidFrameSamples); + } + validate_kind(kind)?; + + Ok(Self { + channels, + sample_rate_hz, + frame_samples, + kind, + cursor_samples: 0, + }) + } + + pub const fn channels(&self) -> u8 { + self.channels + } + + pub const fn sample_rate_hz(&self) -> u32 { + self.sample_rate_hz + } + + pub const fn frame_samples(&self) -> usize { + self.frame_samples + } + + pub const fn cursor_samples(&self) -> u64 { + self.cursor_samples + } + + pub fn next_raw_frame(&mut self) -> Result { + let mut samples = Vec::with_capacity(self.frame_samples * usize::from(self.channels)); + + for frame_index in 0..self.frame_samples { + let absolute_sample = self.cursor_samples + frame_index as u64; + let base = self.sample_at(absolute_sample); + for channel in 0..self.channels { + samples.push(channel_sample(base, channel)); + } + } + + self.cursor_samples += self.frame_samples as u64; + Ok(RawAudioFrame::new(self.channels, samples)?) + } + + fn sample_at(&self, absolute_sample: u64) -> f32 { + match self.kind { + SyntheticSourceKind::Silence => 0.0, + SyntheticSourceKind::Ramp { start, step } => start + step * absolute_sample as f32, + SyntheticSourceKind::Sine { + frequency_hz, + amplitude, + } => { + let t = absolute_sample as f32 / self.sample_rate_hz as f32; + amplitude * (std::f32::consts::TAU * frequency_hz * t).sin() + } + } + } +} + +fn channel_sample(base: f32, channel: u8) -> f32 { + if channel == 0 { + base + } else { + // Deterministic but small channel separation for fixture validation. + base + f32::from(channel) * 0.001 + } +} + +fn validate_kind(kind: SyntheticSourceKind) -> Result<(), SyntheticError> { + match kind { + SyntheticSourceKind::Silence => Ok(()), + SyntheticSourceKind::Ramp { start, step } => { + if start.is_finite() && step.is_finite() { + Ok(()) + } else { + Err(SyntheticError::NonFiniteParameter) + } + } + SyntheticSourceKind::Sine { + frequency_hz, + amplitude, + } => { + if frequency_hz.is_finite() && amplitude.is_finite() { + Ok(()) + } else { + Err(SyntheticError::NonFiniteParameter) + } + } + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct RawFrameCollector { + frames: Vec, + sample_frames: usize, +} + +impl RawFrameCollector { + pub fn new() -> Self { + Self::default() + } + + pub fn push(&mut self, frame: RawAudioFrame) { + self.sample_frames += frame.sample_frames(); + self.frames.push(frame); + } + + pub fn frames(&self) -> &[RawAudioFrame] { + &self.frames + } + + pub const fn sample_frames(&self) -> usize { + self.sample_frames + } + + pub fn into_frames(self) -> Vec { + self.frames + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ramp_source_generates_deterministic_interleaved_channels() { + let mut source = SyntheticSource::new( + 2, + 48_000, + 3, + SyntheticSourceKind::Ramp { + start: 0.0, + step: 0.5, + }, + ) + .unwrap(); + + let first = source.next_raw_frame().unwrap(); + let second = source.next_raw_frame().unwrap(); + + assert_eq!( + first, + RawAudioFrame::new(2, vec![0.0, 0.001, 0.5, 0.501, 1.0, 1.001]).unwrap() + ); + assert_eq!( + second, + RawAudioFrame::new(2, vec![1.5, 1.501, 2.0, 2.001, 2.5, 2.501]).unwrap() + ); + assert_eq!(source.cursor_samples(), 6); + } + + #[test] + fn sine_source_generates_expected_quarter_wave_samples() { + let mut source = SyntheticSource::new( + 1, + 4, + 5, + SyntheticSourceKind::Sine { + frequency_hz: 1.0, + amplitude: 1.0, + }, + ) + .unwrap(); + + let frame = source.next_raw_frame().unwrap(); + let expected = [0.0, 1.0, 0.0, -1.0, 0.0]; + for (sample, expected) in frame.samples.iter().zip(expected) { + assert!((sample - expected).abs() < 0.000_001); + } + } + + #[test] + fn collector_tracks_frames_and_sample_count() { + let mut collector = RawFrameCollector::new(); + collector.push(RawAudioFrame::new(1, vec![0.0, 1.0]).unwrap()); + collector.push(RawAudioFrame::new(2, vec![0.0, 0.1, 0.2, 0.3]).unwrap()); + + assert_eq!(collector.frames().len(), 2); + assert_eq!(collector.sample_frames(), 4); + } + + #[test] + fn invalid_source_parameters_fail_explicitly() { + assert_eq!( + SyntheticSource::new(1, 0, 1, SyntheticSourceKind::Silence), + Err(SyntheticError::InvalidSampleRate) + ); + assert_eq!( + SyntheticSource::new(1, 48_000, 0, SyntheticSourceKind::Silence), + Err(SyntheticError::InvalidFrameSamples) + ); + assert_eq!( + SyntheticSource::new( + 1, + 48_000, + 1, + SyntheticSourceKind::Ramp { + start: f32::NAN, + step: 1.0, + }, + ), + Err(SyntheticError::NonFiniteParameter) + ); + } +} diff --git a/crates/lxst-core/src/telephony.rs b/crates/lxst-core/src/telephony.rs new file mode 100644 index 0000000..261c7a5 --- /dev/null +++ b/crates/lxst-core/src/telephony.rs @@ -0,0 +1,378 @@ +use crate::{Profile, Signal, SignallingStatus}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CallRole { + Incoming, + Outgoing, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TelephonyAction { + SendSignal(Signal), + IdentifyLocalIdentity, + SelectProfile(Profile), + PrepareDialingPipelines, + ResetDialingPipelines, + OpenAudioPipelines, + StartAudioPipelines, + StartDialTone, + Terminate(Option), + TeardownLink, + RingIncomingCall, + SwitchProfile(Profile), + IgnoreSignal(Signal), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TelephonyCall { + role: CallRole, + status: SignallingStatus, + profile: Option, + answered: bool, +} + +impl TelephonyCall { + pub fn outgoing(profile: Option) -> Self { + Self { + role: CallRole::Outgoing, + status: SignallingStatus::Calling, + profile, + answered: false, + } + } + + pub fn incoming() -> Self { + Self { + role: CallRole::Incoming, + status: SignallingStatus::Available, + profile: None, + answered: false, + } + } + + pub const fn role(&self) -> CallRole { + self.role + } + + pub const fn status(&self) -> SignallingStatus { + self.status + } + + pub const fn profile(&self) -> Option { + self.profile + } + + pub const fn answered(&self) -> bool { + self.answered + } + + pub fn incoming_link_established(line_busy: bool) -> Vec { + if line_busy { + vec![ + TelephonyAction::SendSignal(Signal::from(SignallingStatus::Busy)), + TelephonyAction::TeardownLink, + ] + } else { + vec![TelephonyAction::SendSignal(Signal::from( + SignallingStatus::Available, + ))] + } + } + + pub fn caller_identified(&mut self, line_busy: bool, allowed: bool) -> Vec { + if line_busy || !allowed { + return vec![ + TelephonyAction::SendSignal(Signal::from(SignallingStatus::Busy)), + TelephonyAction::TeardownLink, + ]; + } + + let mut actions = Vec::new(); + actions.push(TelephonyAction::ResetDialingPipelines); + self.push_status_signal(SignallingStatus::Ringing, &mut actions); + actions.push(TelephonyAction::RingIncomingCall); + actions + } + + pub fn answer(&mut self) -> Vec { + if self.role != CallRole::Incoming || self.status != SignallingStatus::Ringing { + return Vec::new(); + } + + let mut actions = Vec::new(); + self.answered = true; + self.ensure_profile(&mut actions); + self.push_status_signal(SignallingStatus::Connecting, &mut actions); + actions.push(TelephonyAction::OpenAudioPipelines); + self.push_status_signal(SignallingStatus::Established, &mut actions); + actions.push(TelephonyAction::StartAudioPipelines); + actions + } + + pub fn receive_signal(&mut self, signal: Signal) -> Vec { + if self.role == CallRole::Incoming && !self.answered && matches!(signal, Signal::Status(_)) + { + return vec![TelephonyAction::IgnoreSignal(signal)]; + } + + match signal { + Signal::Status(SignallingStatus::Busy) => { + vec![TelephonyAction::Terminate(Some(SignallingStatus::Busy))] + } + Signal::Status(SignallingStatus::Rejected) => { + vec![TelephonyAction::Terminate(Some(SignallingStatus::Rejected))] + } + Signal::Status(SignallingStatus::Calling) => { + vec![TelephonyAction::IgnoreSignal(signal)] + } + Signal::Status(SignallingStatus::Available) => { + self.status = SignallingStatus::Available; + vec![TelephonyAction::IdentifyLocalIdentity] + } + Signal::Status(SignallingStatus::Ringing) => { + let mut actions = Vec::new(); + self.status = SignallingStatus::Ringing; + self.ensure_profile(&mut actions); + actions.push(TelephonyAction::PrepareDialingPipelines); + if let Some(profile) = self.profile { + actions.push(TelephonyAction::SendSignal(Signal::from(profile))); + } + actions.push(TelephonyAction::StartDialTone); + actions + } + Signal::Status(SignallingStatus::Connecting) => { + self.status = SignallingStatus::Connecting; + vec![ + TelephonyAction::ResetDialingPipelines, + TelephonyAction::OpenAudioPipelines, + ] + } + Signal::Status(SignallingStatus::Established) => { + self.status = SignallingStatus::Established; + vec![TelephonyAction::StartAudioPipelines] + } + Signal::PreferredProfile(profile) => { + if self.profile == Some(profile) { + return Vec::new(); + } + + self.profile = Some(profile); + if self.status == SignallingStatus::Established { + vec![TelephonyAction::SwitchProfile(profile)] + } else { + vec![TelephonyAction::SelectProfile(profile)] + } + } + Signal::Raw(_) => vec![TelephonyAction::IgnoreSignal(signal)], + } + } + + pub fn switch_profile(&mut self, profile: Profile) -> Vec { + if self.profile == Some(profile) { + return Vec::new(); + } + + self.profile = Some(profile); + if self.status == SignallingStatus::Established { + vec![ + TelephonyAction::SendSignal(Signal::from(profile)), + TelephonyAction::SwitchProfile(profile), + ] + } else { + vec![TelephonyAction::SelectProfile(profile)] + } + } + + pub fn hangup(&mut self, ring_timeout: bool) -> Vec { + let mut actions = Vec::new(); + + if self.role == CallRole::Incoming + && self.status == SignallingStatus::Ringing + && !ring_timeout + { + actions.push(TelephonyAction::SendSignal(Signal::from( + SignallingStatus::Rejected, + ))); + } + + actions.push(TelephonyAction::TeardownLink); + self.status = SignallingStatus::Available; + self.answered = false; + actions + } + + fn ensure_profile(&mut self, actions: &mut Vec) { + let profile = self.profile.unwrap_or(Profile::DEFAULT); + self.profile = Some(profile); + actions.push(TelephonyAction::SelectProfile(profile)); + } + + fn push_status_signal(&mut self, status: SignallingStatus, actions: &mut Vec) { + if status.is_auto_status() { + self.status = status; + } + actions.push(TelephonyAction::SendSignal(Signal::from(status))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn incoming_link_establishment_matches_python_busy_branch() { + assert_eq!( + TelephonyCall::incoming_link_established(false), + vec![TelephonyAction::SendSignal(Signal::from( + SignallingStatus::Available + ))] + ); + assert_eq!( + TelephonyCall::incoming_link_established(true), + vec![ + TelephonyAction::SendSignal(Signal::from(SignallingStatus::Busy)), + TelephonyAction::TeardownLink, + ] + ); + } + + #[test] + fn incoming_identified_then_answered_sequence_matches_python() { + let mut call = TelephonyCall::incoming(); + let ringing = call.caller_identified(false, true); + assert_eq!(call.status(), SignallingStatus::Ringing); + assert_eq!( + ringing, + vec![ + TelephonyAction::ResetDialingPipelines, + TelephonyAction::SendSignal(Signal::from(SignallingStatus::Ringing)), + TelephonyAction::RingIncomingCall, + ] + ); + + let answer = call.answer(); + assert_eq!(call.status(), SignallingStatus::Established); + assert!(call.answered()); + assert_eq!(call.profile(), Some(Profile::DEFAULT)); + assert_eq!( + answer, + vec![ + TelephonyAction::SelectProfile(Profile::DEFAULT), + TelephonyAction::SendSignal(Signal::from(SignallingStatus::Connecting)), + TelephonyAction::OpenAudioPipelines, + TelephonyAction::SendSignal(Signal::from(SignallingStatus::Established)), + TelephonyAction::StartAudioPipelines, + ] + ); + } + + #[test] + fn outgoing_sequence_identifies_profiles_opens_and_starts_audio() { + let mut call = TelephonyCall::outgoing(None); + + assert_eq!( + call.receive_signal(Signal::from(SignallingStatus::Available)), + vec![TelephonyAction::IdentifyLocalIdentity] + ); + + let ringing = call.receive_signal(Signal::from(SignallingStatus::Ringing)); + assert_eq!(call.profile(), Some(Profile::DEFAULT)); + assert_eq!( + ringing, + vec![ + TelephonyAction::SelectProfile(Profile::DEFAULT), + TelephonyAction::PrepareDialingPipelines, + TelephonyAction::SendSignal(Signal::from(Profile::DEFAULT)), + TelephonyAction::StartDialTone, + ] + ); + + assert_eq!( + call.receive_signal(Signal::from(SignallingStatus::Connecting)), + vec![ + TelephonyAction::ResetDialingPipelines, + TelephonyAction::OpenAudioPipelines, + ] + ); + assert_eq!( + call.receive_signal(Signal::from(SignallingStatus::Established)), + vec![TelephonyAction::StartAudioPipelines] + ); + assert_eq!(call.status(), SignallingStatus::Established); + } + + #[test] + fn profile_signals_select_or_switch_profile_by_call_status() { + let mut call = TelephonyCall::outgoing(None); + assert_eq!( + call.receive_signal(Signal::from(Profile::LatencyLow)), + vec![TelephonyAction::SelectProfile(Profile::LatencyLow)] + ); + + call.receive_signal(Signal::from(SignallingStatus::Established)); + assert_eq!( + call.receive_signal(Signal::from(Profile::LatencyUltraLow)), + vec![TelephonyAction::SwitchProfile(Profile::LatencyUltraLow)] + ); + } + + #[test] + fn duplicate_profile_signals_do_not_reconfigure_audio() { + let mut call = TelephonyCall::outgoing(Some(Profile::QualityHigh)); + call.receive_signal(Signal::from(SignallingStatus::Established)); + + assert!( + call.receive_signal(Signal::from(Profile::QualityHigh)) + .is_empty() + ); + } + + #[test] + fn local_profile_switch_signals_remote_and_reconfigures_established_call() { + let mut call = TelephonyCall::outgoing(Some(Profile::QualityMedium)); + call.receive_signal(Signal::from(SignallingStatus::Established)); + + assert_eq!( + call.switch_profile(Profile::QualityHigh), + vec![ + TelephonyAction::SendSignal(Signal::from(Profile::QualityHigh)), + TelephonyAction::SwitchProfile(Profile::QualityHigh), + ] + ); + assert_eq!(call.profile(), Some(Profile::QualityHigh)); + assert!(call.switch_profile(Profile::QualityHigh).is_empty()); + } + + #[test] + fn incoming_call_ignores_status_signals_before_answer() { + let mut call = TelephonyCall::incoming(); + call.caller_identified(false, true); + + assert_eq!( + call.receive_signal(Signal::from(SignallingStatus::Established)), + vec![TelephonyAction::IgnoreSignal(Signal::from( + SignallingStatus::Established + ))] + ); + } + + #[test] + fn incoming_hangup_sends_rejected_while_ringing_unless_timeout() { + let mut call = TelephonyCall::incoming(); + call.caller_identified(false, true); + assert_eq!( + call.hangup(false), + vec![ + TelephonyAction::SendSignal(Signal::from(SignallingStatus::Rejected)), + TelephonyAction::TeardownLink, + ] + ); + + let mut timeout_call = TelephonyCall::incoming(); + timeout_call.caller_identified(false, true); + assert_eq!( + timeout_call.hangup(true), + vec![TelephonyAction::TeardownLink] + ); + } +} diff --git a/crates/lxst-core/src/wire.rs b/crates/lxst-core/src/wire.rs new file mode 100644 index 0000000..a1e0b01 --- /dev/null +++ b/crates/lxst-core/src/wire.rs @@ -0,0 +1,485 @@ +use rmpv::Value; +use rmpv::decode::read_value; +use rmpv::encode::write_value; +use thiserror::Error; + +use crate::profile::{Profile, SignallingStatus}; + +pub const FIELD_SIGNALLING: u8 = 0x00; +pub const FIELD_FRAMES: u8 = 0x01; + +const PREFERRED_PROFILE_BASE: u32 = 0xFF; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum Error { + #[error("msgpack decode error: {0}")] + Decode(String), + #[error("msgpack encode error: {0}")] + Encode(String), + #[error("LXST packet root must be a msgpack map")] + RootNotMap, + #[error("LXST field key must be a non-negative integer")] + InvalidFieldKey, + #[error("LXST field {field:#04x} has invalid value type")] + InvalidFieldType { field: u8 }, + #[error("LXST signal value must be a non-negative integer")] + InvalidSignal, + #[error("LXST frame must contain a codec header byte")] + EmptyFrame, + #[error("unknown LXST codec id {0:#04x}")] + UnknownCodec(u8), + #[error("LXST codec {0:?} is not transmittable as a media frame")] + NonTransmittableCodec(CodecKind), + #[error("expected raw codec frame, got {0:?}")] + InvalidRawFrameCodec(CodecKind), + #[error("invalid raw channel count {0}; expected 1..=64")] + InvalidRawChannels(u8), + #[error("unknown raw bit-depth header {0}")] + UnknownRawBitDepth(u8), + #[error("raw payload is empty; expected one header byte plus sample data")] + EmptyRawPayload, + #[error("raw payload sample bytes are not aligned to {bytes_per_sample}-byte samples")] + InvalidRawSampleBytes { bytes_per_sample: usize }, + #[error("raw sample count {samples} is not divisible by channel count {channels}")] + InvalidRawSampleCount { samples: usize, channels: u8 }, + #[error("unknown Codec2 mode header {0:#04x}")] + UnknownCodec2Mode(u8), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum CodecKind { + Raw = 0x00, + Opus = 0x01, + Codec2 = 0x02, + Null = 0xFF, +} + +impl CodecKind { + pub const fn wire_id(self) -> u8 { + self as u8 + } + + pub const fn from_wire(id: u8) -> Result { + match id { + 0x00 => Ok(Self::Raw), + 0x01 => Ok(Self::Opus), + 0x02 => Ok(Self::Codec2), + 0xFF => Ok(Self::Null), + other => Err(Error::UnknownCodec(other)), + } + } + + pub const fn is_transmittable(self) -> bool { + !matches!(self, Self::Null) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Signal { + Status(SignallingStatus), + PreferredProfile(Profile), + Raw(u32), +} + +impl Signal { + pub const fn wire_value(self) -> u32 { + match self { + Self::Status(status) => status.wire_value(), + Self::PreferredProfile(profile) => PREFERRED_PROFILE_BASE + profile.wire_value(), + Self::Raw(value) => value, + } + } + + pub const fn from_wire(value: u32) -> Self { + if let Some(status) = SignallingStatus::from_wire(value) { + Self::Status(status) + } else if value >= PREFERRED_PROFILE_BASE { + let profile_value = value - PREFERRED_PROFILE_BASE; + if let Some(profile) = Profile::from_wire(profile_value) { + Self::PreferredProfile(profile) + } else { + Self::Raw(value) + } + } else { + Self::Raw(value) + } + } +} + +impl From for Signal { + fn from(value: SignallingStatus) -> Self { + Self::Status(value) + } +} + +impl From for Signal { + fn from(value: Profile) -> Self { + Self::PreferredProfile(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub codec: CodecKind, + pub payload: Vec, +} + +impl Frame { + pub fn new(codec: CodecKind, payload: impl Into>) -> Self { + Self { + codec, + payload: payload.into(), + } + } + + pub fn from_wire_bytes(bytes: &[u8]) -> Result { + let Some((&codec_id, payload)) = bytes.split_first() else { + return Err(Error::EmptyFrame); + }; + + let codec = CodecKind::from_wire(codec_id)?; + if !codec.is_transmittable() { + return Err(Error::NonTransmittableCodec(codec)); + } + + Ok(Self { + codec, + payload: payload.to_vec(), + }) + } + + pub fn to_wire_bytes(&self) -> Result, Error> { + if !self.codec.is_transmittable() { + return Err(Error::NonTransmittableCodec(self.codec)); + } + + let mut bytes = Vec::with_capacity(1 + self.payload.len()); + bytes.push(self.codec.wire_id()); + bytes.extend_from_slice(&self.payload); + Ok(bytes) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct LxstPacket { + pub signals: Vec, + pub frames: Vec, +} + +impl LxstPacket { + pub fn new() -> Self { + Self::default() + } + + pub fn signalling(signals: impl IntoIterator) -> Self { + Self { + signals: signals.into_iter().collect(), + frames: Vec::new(), + } + } + + pub fn frame(frame: Frame) -> Self { + Self { + signals: Vec::new(), + frames: vec![frame], + } + } + + pub fn encode(&self) -> Result, Error> { + let mut map = Vec::with_capacity(2); + + if !self.signals.is_empty() { + let signals = self + .signals + .iter() + .map(|signal| Value::from(signal.wire_value() as u64)) + .collect(); + map.push((Value::from(FIELD_SIGNALLING as u64), Value::Array(signals))); + } + + if !self.frames.is_empty() { + let value = if self.frames.len() == 1 { + Value::Binary(self.frames[0].to_wire_bytes()?) + } else { + Value::Array( + self.frames + .iter() + .map(|frame| frame.to_wire_bytes().map(Value::Binary)) + .collect::, _>>()?, + ) + }; + map.push((Value::from(FIELD_FRAMES as u64), value)); + } + + let mut out = Vec::new(); + write_value(&mut out, &Value::Map(map)).map_err(|e| Error::Encode(e.to_string()))?; + Ok(out) + } + + pub fn decode(bytes: &[u8]) -> Result { + let value = read_value(&mut &bytes[..]).map_err(|e| Error::Decode(e.to_string()))?; + Self::from_value(value) + } + + fn from_value(value: Value) -> Result { + let Value::Map(entries) = value else { + return Err(Error::RootNotMap); + }; + + let mut packet = Self::new(); + for (key, value) in entries { + let Some(field) = integer_value(&key).and_then(|v| u8::try_from(v).ok()) else { + return Err(Error::InvalidFieldKey); + }; + + match field { + FIELD_SIGNALLING => { + packet.signals.extend(parse_signals(value)?); + } + FIELD_FRAMES => { + packet.frames.extend(parse_frames(value)?); + } + _ => {} + } + } + + Ok(packet) + } +} + +fn parse_signals(value: Value) -> Result, Error> { + match value { + Value::Array(values) => values + .iter() + .map(parse_signal) + .collect::, _>>(), + other => Ok(vec![parse_signal(&other)?]), + } +} + +fn parse_signal(value: &Value) -> Result { + let Some(raw) = integer_value(value).and_then(|v| u32::try_from(v).ok()) else { + return Err(Error::InvalidSignal); + }; + Ok(Signal::from_wire(raw)) +} + +fn parse_frames(value: Value) -> Result, Error> { + match value { + Value::Binary(bytes) => Ok(vec![Frame::from_wire_bytes(&bytes)?]), + Value::Array(values) => values + .into_iter() + .map(|value| match value { + Value::Binary(bytes) => Frame::from_wire_bytes(&bytes), + _ => Err(Error::InvalidFieldType { + field: FIELD_FRAMES, + }), + }) + .collect(), + _ => Err(Error::InvalidFieldType { + field: FIELD_FRAMES, + }), + } +} + +fn integer_value(value: &Value) -> Option { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|v| u64::try_from(v).ok())) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum RawBitDepth { + Float16 = 0x00, + Float32 = 0x01, + Float64 = 0x02, + Float128 = 0x03, +} + +impl RawBitDepth { + pub const fn from_header_bits(bits: u8) -> Result { + match bits { + 0x00 => Ok(Self::Float16), + 0x01 => Ok(Self::Float32), + 0x02 => Ok(Self::Float64), + 0x03 => Ok(Self::Float128), + other => Err(Error::UnknownRawBitDepth(other)), + } + } + + pub const fn bits(self) -> u16 { + match self { + Self::Float16 => 16, + Self::Float32 => 32, + Self::Float64 => 64, + Self::Float128 => 128, + } + } + + pub const fn bytes_per_sample(self) -> usize { + (self.bits() as usize) / 8 + } + + pub const fn numpy_dtype(self) -> &'static str { + match self { + Self::Float16 => "float16", + Self::Float32 => "float32", + Self::Float64 => "float64", + Self::Float128 => "float128", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RawFrameHeader { + pub channels: u8, + pub bit_depth: RawBitDepth, +} + +impl RawFrameHeader { + pub const fn new(channels: u8, bit_depth: RawBitDepth) -> Result { + if channels == 0 || channels > 64 { + Err(Error::InvalidRawChannels(channels)) + } else { + Ok(Self { + channels, + bit_depth, + }) + } + } + + pub fn parse(byte: u8) -> Result { + let channels = (byte & 0b0011_1111) + 1; + let bit_depth = RawBitDepth::from_header_bits(byte >> 6)?; + Self::new(channels, bit_depth) + } + + pub const fn encode(self) -> u8 { + ((self.bit_depth as u8) << 6) | (self.channels - 1) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum Codec2Mode { + Mode700C = 0x00, + Mode1200 = 0x01, + Mode1300 = 0x02, + Mode1400 = 0x03, + Mode1600 = 0x04, + Mode2400 = 0x05, + Mode3200 = 0x06, +} + +impl Codec2Mode { + pub const fn from_header(byte: u8) -> Result { + match byte { + 0x00 => Ok(Self::Mode700C), + 0x01 => Ok(Self::Mode1200), + 0x02 => Ok(Self::Mode1300), + 0x03 => Ok(Self::Mode1400), + 0x04 => Ok(Self::Mode1600), + 0x05 => Ok(Self::Mode2400), + 0x06 => Ok(Self::Mode3200), + other => Err(Error::UnknownCodec2Mode(other)), + } + } + + pub const fn header(self) -> u8 { + self as u8 + } + + pub const fn bitrate(self) -> u16 { + match self { + Self::Mode700C => 700, + Self::Mode1200 => 1200, + Self::Mode1300 => 1300, + Self::Mode1400 => 1400, + Self::Mode1600 => 1600, + Self::Mode2400 => 2400, + Self::Mode3200 => 3200, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codec_header_mapping_matches_python() { + assert_eq!(CodecKind::Raw.wire_id(), 0x00); + assert_eq!(CodecKind::Opus.wire_id(), 0x01); + assert_eq!(CodecKind::Codec2.wire_id(), 0x02); + assert_eq!(CodecKind::Null.wire_id(), 0xFF); + assert_eq!(CodecKind::from_wire(0x02), Ok(CodecKind::Codec2)); + assert_eq!(CodecKind::from_wire(0x03), Err(Error::UnknownCodec(0x03))); + } + + #[test] + fn raw_header_roundtrip() { + let header = RawFrameHeader::new(2, RawBitDepth::Float32).unwrap(); + assert_eq!(header.encode(), 0x41); + assert_eq!(RawFrameHeader::parse(0x41), Ok(header)); + + let max = RawFrameHeader::new(64, RawBitDepth::Float128).unwrap(); + assert_eq!(RawFrameHeader::parse(max.encode()), Ok(max)); + assert_eq!( + RawFrameHeader::new(0, RawBitDepth::Float16), + Err(Error::InvalidRawChannels(0)) + ); + } + + #[test] + fn codec2_mode_headers_match_python() { + assert_eq!(Codec2Mode::Mode700C.header(), 0x00); + assert_eq!(Codec2Mode::Mode1600.header(), 0x04); + assert_eq!(Codec2Mode::Mode3200.header(), 0x06); + assert_eq!(Codec2Mode::Mode700C.bitrate(), 700); + assert_eq!( + Codec2Mode::from_header(0x07), + Err(Error::UnknownCodec2Mode(0x07)) + ); + } + + #[test] + fn frame_roundtrip() { + let frame = Frame::new(CodecKind::Raw, [0x41, 0xAA, 0xBB]); + let wire = frame.to_wire_bytes().unwrap(); + assert_eq!(wire, vec![0x00, 0x41, 0xAA, 0xBB]); + assert_eq!(Frame::from_wire_bytes(&wire), Ok(frame)); + } + + #[test] + fn null_codec_is_known_but_not_transmittable() { + assert_eq!(CodecKind::from_wire(0xFF), Ok(CodecKind::Null)); + assert_eq!( + Frame::from_wire_bytes(&[0xFF]), + Err(Error::NonTransmittableCodec(CodecKind::Null)) + ); + assert_eq!( + LxstPacket::frame(Frame::new(CodecKind::Null, [])).encode(), + Err(Error::NonTransmittableCodec(CodecKind::Null)) + ); + } + + #[test] + fn signal_profile_base_matches_python() { + let signal = Signal::from(Profile::QualityMedium); + assert_eq!(signal.wire_value(), 0xFF + 0x40); + assert_eq!( + Signal::from_wire(0xFF + 0x70), + Signal::PreferredProfile(Profile::LatencyUltraLow) + ); + } + + #[test] + fn packet_encodes_single_frame_as_binary_field() { + let packet = LxstPacket::frame(Frame::new(CodecKind::Raw, [0x00])); + let encoded = packet.encode().unwrap(); + assert_eq!(encoded, vec![0x81, 0x01, 0xC4, 0x02, 0x00, 0x00]); + assert_eq!(LxstPacket::decode(&encoded), Ok(packet)); + } +} diff --git a/crates/lxst-core/tests/malformed_wire.rs b/crates/lxst-core/tests/malformed_wire.rs new file mode 100644 index 0000000..f58c307 --- /dev/null +++ b/crates/lxst-core/tests/malformed_wire.rs @@ -0,0 +1,128 @@ +use lxst_core::{ + CodecKind, Error, FIELD_FRAMES, FIELD_SIGNALLING, Frame, LxstPacket, RawAudioFrame, RawBitDepth, +}; +use proptest::prelude::*; +use rmpv::Value; + +fn encode_value(value: Value) -> Vec { + let mut out = Vec::new(); + rmpv::encode::write_value(&mut out, &value).expect("encode msgpack value"); + out +} + +#[test] +fn malformed_msgpack_shapes_fail_deterministically() { + let cases = [ + ( + Value::Array(vec![]), + Error::RootNotMap, + "root array is not an LXST packet", + ), + ( + Value::Map(vec![(Value::from(-1), Value::from(0))]), + Error::InvalidFieldKey, + "negative field key", + ), + ( + Value::Map(vec![(Value::from(999), Value::from(0))]), + Error::InvalidFieldKey, + "oversized field key", + ), + ( + Value::Map(vec![( + Value::from(FIELD_SIGNALLING as u64), + Value::String("bad".into()), + )]), + Error::InvalidSignal, + "non-integer signal", + ), + ( + Value::Map(vec![( + Value::from(FIELD_FRAMES as u64), + Value::Array(vec![Value::from(1)]), + )]), + Error::InvalidFieldType { + field: FIELD_FRAMES, + }, + "non-bytes frame in list", + ), + ( + Value::Map(vec![( + Value::from(FIELD_FRAMES as u64), + Value::Binary(vec![]), + )]), + Error::EmptyFrame, + "empty media frame", + ), + ( + Value::Map(vec![( + Value::from(FIELD_FRAMES as u64), + Value::Binary(vec![0x03]), + )]), + Error::UnknownCodec(0x03), + "unknown media codec", + ), + ( + Value::Map(vec![( + Value::from(FIELD_FRAMES as u64), + Value::Binary(vec![0xFF]), + )]), + Error::NonTransmittableCodec(CodecKind::Null), + "null media codec is not transmittable", + ), + ]; + + for (value, expected, name) in cases { + assert_eq!( + LxstPacket::decode(&encode_value(value)), + Err(expected), + "{name}" + ); + } +} + +#[test] +fn decoder_tolerates_trailing_bytes_like_python_umsgpack() { + let mut encoded = LxstPacket::frame(Frame::new(CodecKind::Raw, [0x00, 0x00])) + .encode() + .unwrap(); + encoded.extend_from_slice(b"trailing"); + + let decoded = LxstPacket::decode(&encoded).unwrap(); + assert_eq!(decoded.frames.len(), 1); +} + +#[test] +fn raw_payload_errors_are_explicit() { + assert_eq!( + RawAudioFrame::from_payload(&[0x40, 0x00]), + Err(Error::InvalidRawSampleBytes { + bytes_per_sample: 4, + }) + ); + assert_eq!( + RawAudioFrame::new(2, vec![0.0]), + Err(Error::InvalidRawSampleCount { + samples: 1, + channels: 2, + }) + ); + assert_eq!( + RawAudioFrame::new(1, vec![0.0]) + .unwrap() + .to_frame(RawBitDepth::Float16) + .unwrap() + .codec, + CodecKind::Raw + ); +} + +proptest! { + #[test] + fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::(), 0..1024)) { + let result = std::panic::catch_unwind(|| { + let _ = LxstPacket::decode(&bytes); + }); + prop_assert!(result.is_ok()); + } +} diff --git a/crates/lxst-core/tests/python_raw_parity.rs b/crates/lxst-core/tests/python_raw_parity.rs new file mode 100644 index 0000000..b832f5e --- /dev/null +++ b/crates/lxst-core/tests/python_raw_parity.rs @@ -0,0 +1,161 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use lxst_core::{RawAudioFrame, RawBitDepth}; +use serde_json::Value; + +const SKIP_ENV: &str = "SKIP_PYTHON_LXST_INTEROP"; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("crate is under rsLXST/crates/lxst-core") + .to_path_buf() +} + +fn fixture_script() -> PathBuf { + repo_root().join("tools/fixtures/lxst_raw_fixtures.py") +} + +fn should_skip() -> bool { + std::env::var(SKIP_ENV).map(|v| v == "1").unwrap_or(false) +} + +fn python_fixtures() -> Vec { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping Python LXST Raw parity"); + return Vec::new(); + } + + let output = Command::new("python3") + .arg(fixture_script()) + .output() + .expect("spawn Python Raw fixture generator"); + + assert!( + output.status.success(), + "Python Raw fixture generator failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + serde_json::from_slice(&output.stdout).expect("fixture JSON") +} + +fn decode_with_python(payload_hex: &str) -> Value { + let output = Command::new("python3") + .arg(fixture_script()) + .arg("--decode-hex") + .arg(payload_hex) + .output() + .expect("spawn Python Raw fixture decoder"); + + assert!( + output.status.success(), + "Python Raw fixture decoder failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + serde_json::from_slice(&output.stdout).expect("decode JSON") +} + +fn bit_depth(value: &Value) -> RawBitDepth { + match value["bitdepth_header"].as_u64().expect("bitdepth header") { + 0 => RawBitDepth::Float16, + 1 => RawBitDepth::Float32, + 2 => RawBitDepth::Float64, + 3 => RawBitDepth::Float128, + other => panic!("unknown bitdepth header {other}"), + } +} + +fn samples(value: &Value) -> Vec { + value["samples"] + .as_array() + .expect("samples") + .iter() + .map(|sample| sample.as_f64().expect("sample") as f32) + .collect() +} + +fn assert_samples_close(actual: &[f32], expected: &[f32], name: &str) { + assert_eq!(actual.len(), expected.len(), "{name}"); + for (index, (actual, expected)) in actual.iter().zip(expected).enumerate() { + let delta = (actual - expected).abs(); + assert!( + delta <= 0.000_976_562_5, + "{name} sample {index}: actual {actual} expected {expected} delta {delta}", + ); + } +} + +#[test] +fn rust_decodes_python_raw_payloads() { + for fixture in python_fixtures() { + let name = fixture["name"].as_str().expect("fixture name"); + let payload_hex = fixture["payload_hex"].as_str().expect("payload hex"); + let payload = hex::decode(payload_hex).expect("payload hex decodes"); + let raw = RawAudioFrame::from_payload(&payload) + .unwrap_or_else(|e| panic!("failed to decode fixture {name}: {e}")); + let depth = bit_depth(&fixture); + let expected_samples = samples(&fixture); + + assert_eq!( + raw.channels, + fixture["channels"].as_u64().expect("channels") as u8, + "{name}", + ); + assert_eq!( + raw.sample_frames(), + fixture["sample_frames"].as_u64().expect("sample frames") as usize, + "{name}", + ); + assert_samples_close(&raw.samples, &expected_samples, name); + assert_eq!( + hex::encode(raw.to_payload(depth).unwrap()), + payload_hex, + "{name}" + ); + } +} + +#[test] +fn python_decodes_rust_raw_payloads() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping Python LXST Raw parity"); + return; + } + + let cases = [ + ( + RawAudioFrame::new(2, vec![0.0, 0.5, -0.25, 1.0]).unwrap(), + RawBitDepth::Float16, + ), + ( + RawAudioFrame::new(1, vec![0.25, -1.5, 2.0]).unwrap(), + RawBitDepth::Float32, + ), + ( + RawAudioFrame::new(3, vec![0.0, 0.125, -0.5, 1.0, -1.0, 0.25]).unwrap(), + RawBitDepth::Float64, + ), + ]; + + for (raw, depth) in cases { + let payload_hex = hex::encode(raw.to_payload(depth).expect("encode Raw payload")); + let decoded = decode_with_python(&payload_hex); + + assert_eq!( + decoded["channels"].as_u64().expect("channels") as u8, + raw.channels + ); + assert_eq!( + decoded["sample_frames"].as_u64().expect("sample frames") as usize, + raw.sample_frames(), + ); + assert_eq!(bit_depth(&decoded), depth); + assert_samples_close(&samples(&decoded), &raw.samples, &payload_hex); + } +} diff --git a/crates/lxst-core/tests/python_wire_parity.rs b/crates/lxst-core/tests/python_wire_parity.rs new file mode 100644 index 0000000..d777d8c --- /dev/null +++ b/crates/lxst-core/tests/python_wire_parity.rs @@ -0,0 +1,150 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use lxst_core::{CodecKind, Frame, LxstPacket, Profile, Signal, SignallingStatus}; +use serde_json::Value; + +const SKIP_ENV: &str = "SKIP_PYTHON_LXST_INTEROP"; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("crate is under rsLXST/crates/lxst-core") + .to_path_buf() +} + +fn fixture_script() -> PathBuf { + repo_root().join("tools/fixtures/lxst_wire_fixtures.py") +} + +fn should_skip() -> bool { + std::env::var(SKIP_ENV).map(|v| v == "1").unwrap_or(false) +} + +fn python_fixtures() -> Vec { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping Python LXST wire parity"); + return Vec::new(); + } + + let output = Command::new("python3") + .arg(fixture_script()) + .output() + .expect("spawn Python fixture generator"); + + assert!( + output.status.success(), + "Python fixture generator failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + serde_json::from_slice(&output.stdout).expect("fixture JSON") +} + +fn decode_with_python(packet_hex: &str) -> Value { + let output = Command::new("python3") + .arg(fixture_script()) + .arg("--decode-hex") + .arg(packet_hex) + .output() + .expect("spawn Python fixture decoder"); + + assert!( + output.status.success(), + "Python fixture decoder failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + serde_json::from_slice(&output.stdout).expect("decode JSON") +} + +fn signal_values(packet: &LxstPacket) -> Vec { + packet.signals.iter().map(|s| s.wire_value()).collect() +} + +#[test] +fn rust_decodes_python_lxst_packets() { + for fixture in python_fixtures() { + let name = fixture["name"].as_str().expect("fixture name"); + let packet_hex = fixture["packet_hex"].as_str().expect("packet hex"); + let packet_bytes = hex::decode(packet_hex).expect("packet hex decodes"); + let packet = LxstPacket::decode(&packet_bytes) + .unwrap_or_else(|e| panic!("failed to decode fixture {name}: {e}")); + + let expected_signals: Vec = fixture["signals"] + .as_array() + .expect("signals array") + .iter() + .map(|v| v.as_u64().expect("signal int") as u32) + .collect(); + assert_eq!(signal_values(&packet), expected_signals, "{name}"); + + let expected_frames = fixture["frames"].as_array().expect("frames array"); + assert_eq!(packet.frames.len(), expected_frames.len(), "{name}"); + + for (frame, expected) in packet.frames.iter().zip(expected_frames) { + let codec = expected["codec"].as_u64().expect("codec") as u8; + assert_eq!(frame.codec.wire_id(), codec, "{name}"); + let payload = expected["payload_hex"].as_str().expect("payload hex"); + assert_eq!(hex::encode(&frame.payload), payload, "{name}"); + } + + if fixture["canonical"].as_bool().unwrap_or(false) { + assert_eq!(hex::encode(packet.encode().unwrap()), packet_hex, "{name}"); + } + } +} + +#[test] +fn python_decodes_rust_lxst_packets() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping Python LXST wire parity"); + return; + } + + let packets = [ + LxstPacket::signalling([ + Signal::from(SignallingStatus::Available), + Signal::from(Profile::QualityMedium), + ]), + LxstPacket::frame(Frame::new(CodecKind::Raw, [0x41, 0x01, 0x02, 0x03, 0x04])), + LxstPacket { + signals: vec![ + Signal::from(SignallingStatus::Established), + Signal::from(Profile::LatencyUltraLow), + ], + frames: vec![ + Frame::new(CodecKind::Raw, [0x41, 0x01, 0x02, 0x03, 0x04]), + Frame::new(CodecKind::Codec2, [0x04, 0xAA, 0xBB]), + ], + }, + ]; + + for packet in packets { + let packet_hex = hex::encode(packet.encode().expect("encode packet")); + let decoded = decode_with_python(&packet_hex); + let signals: Vec = decoded["signals"] + .as_array() + .expect("signals") + .iter() + .map(|v| v.as_u64().expect("signal") as u32) + .collect(); + assert_eq!(signals, signal_values(&packet)); + + let frames = decoded["frames"].as_array().expect("frames"); + assert_eq!(frames.len(), packet.frames.len()); + for (frame, expected) in packet.frames.iter().zip(frames) { + assert_eq!( + expected["codec"].as_u64().expect("codec") as u8, + frame.codec.wire_id() + ); + assert_eq!( + expected["payload_hex"].as_str().expect("payload"), + hex::encode(&frame.payload) + ); + } + } +} diff --git a/crates/lxst-core/tests/reference_snapshot.rs b/crates/lxst-core/tests/reference_snapshot.rs new file mode 100644 index 0000000..5f915c4 --- /dev/null +++ b/crates/lxst-core/tests/reference_snapshot.rs @@ -0,0 +1,82 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::Value; + +const SKIP_ENV: &str = "SKIP_PYTHON_LXST_INTEROP"; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("crate is under rsLXST/crates/lxst-core") + .to_path_buf() +} + +#[test] +fn python_lxst_reference_snapshot_is_available() { + if std::env::var(SKIP_ENV).map(|v| v == "1").unwrap_or(false) { + eprintln!("{SKIP_ENV}=1 -> skipping Python LXST reference snapshot"); + return; + } + + let script = repo_root().join("tools/reference/lxst_reference_snapshot.py"); + let output = Command::new("python3") + .arg(script) + .output() + .expect("spawn Python LXST reference snapshot"); + + assert!( + output.status.success(), + "Python LXST reference snapshot failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let snapshot: Value = serde_json::from_slice(&output.stdout).expect("snapshot JSON"); + let lock_path = repo_root().join("tools/reference/lxst_reference_lock.json"); + let locked: Value = + serde_json::from_slice(&fs::read(lock_path).expect("read LXST reference lock")) + .expect("reference lock JSON"); + + assert_eq!( + snapshot["remote"].as_str(), + Some("https://github.com/markqvist/LXST.git") + ); + assert_eq!(snapshot["dirty"].as_bool(), Some(false)); + assert_eq!(snapshot["missing_files"].as_array().unwrap().len(), 0); + assert_eq!( + snapshot["remote"], locked["remote"], + "LXST upstream remote changed" + ); + assert_eq!( + snapshot["commit"], locked["commit"], + "LXST source-of-truth commit changed; review upstream diff and update tools/reference/lxst_reference_lock.json intentionally" + ); + assert_eq!( + snapshot["package_version"], locked["package_version"], + "LXST package version changed" + ); + + let files = snapshot["files"].as_object().expect("files object"); + let locked_files = locked["files"].as_object().expect("locked files object"); + for rel in [ + "LXST/_version.py", + "LXST/Network.py", + "LXST/Primitives/Telephony.py", + "LXST/Codecs/__init__.py", + "LXST/Codecs/Raw.py", + "LXST/Codecs/Opus.py", + "LXST/Codecs/Codec2.py", + ] { + let entry = files.get(rel).unwrap_or_else(|| panic!("missing {rel}")); + let locked_entry = locked_files + .get(rel) + .unwrap_or_else(|| panic!("missing locked {rel}")); + let sha = entry["sha256"].as_str().expect("sha256"); + assert_eq!(sha.len(), 64, "{rel}"); + assert!(entry["bytes"].as_u64().unwrap_or(0) > 0, "{rel}"); + assert_eq!(entry, locked_entry, "LXST reference file changed: {rel}"); + } +} diff --git a/crates/lxst-rns/Cargo.toml b/crates/lxst-rns/Cargo.toml new file mode 100644 index 0000000..8b226e2 --- /dev/null +++ b/crates/lxst-rns/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lxst-rns" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +bytes.workspace = true +lxst-core.workspace = true +rns-link.workspace = true +rns-transport.workspace = true +rns-wire.workspace = true +thiserror.workspace = true +tokio.workspace = true + +[dev-dependencies] +rns-crypto.workspace = true diff --git a/crates/lxst-rns/src/lib.rs b/crates/lxst-rns/src/lib.rs new file mode 100644 index 0000000..c46a835 --- /dev/null +++ b/crates/lxst-rns/src/lib.rs @@ -0,0 +1,659 @@ +//! Reticulum transport boundary for LXST. +//! +//! This crate is intentionally small: it turns already-encoded LXST packets into +//! Reticulum link data packets and leaves call state, audio, and codec work to +//! higher layers. + +use bytes::Bytes; +use lxst_core::{ + DropPolicy, Frame, FramePacketizer, FrameStreamEvent, FrameStreamState, JitterBuffer, + JitterPush, JitterStats, LxstPacket, OpusDecoderState, RawAudioFrame, RawBitDepth, +}; +use rns_link::link::{Link, LinkState}; +use rns_transport::messages::{OutboundRequest, TransportMessage}; +use thiserror::Error; +use tokio::sync::mpsc; + +#[derive(Debug, Error)] +pub enum Error { + #[error("LXST core error: {0}")] + Lxst(#[from] lxst_core::Error), + #[error("LXST stream error: {0}")] + Stream(#[from] lxst_core::StreamError), + #[error("LXST Opus codec error: {0}")] + Opus(#[from] lxst_core::OpusCodecError), + #[error("Reticulum link is not active: {0:?}")] + LinkNotActive(LinkState), + #[error("LXST payload length {payload_len} exceeds link MDU {mdu}")] + PayloadExceedsMdu { payload_len: usize, mdu: usize }, + #[error("Reticulum link encryption failed: {0}")] + LinkEncrypt(String), + #[error("Reticulum outbound queue is closed")] + TransportClosed, + #[error("Reticulum outbound queue is full")] + TransportFull, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PackedLinkPacket { + pub raw: Bytes, + pub packet_hash: [u8; 32], + pub destination_hash: [u8; 16], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InboundLxstPacket { + pub link_id: [u8; 16], + pub packet: LxstPacket, + pub frame_events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MediaIngressResult { + pub inbound: InboundLxstPacket, + pub jitter_pushes: Vec>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LxstLinkIngress { + frame_stream: FrameStreamState, +} + +impl LxstLinkIngress { + pub fn new() -> Self { + Self::default() + } + + pub const fn current_codec(&self) -> Option { + self.frame_stream.current_codec() + } + + /// Decode decrypted plaintext from `LinkManager::set_link_packet_channel`. + pub fn accept_plaintext( + &mut self, + link_id: [u8; 16], + payload: &[u8], + ) -> Result { + let packet = LxstPacket::decode(payload)?; + let frame_events = self.frame_stream.accept_packet(packet.clone()); + + Ok(InboundLxstPacket { + link_id, + packet, + frame_events, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct LxstMediaEgress { + packetizer: FramePacketizer, +} + +impl LxstMediaEgress { + pub const PYTHON_COMPATIBLE: Self = Self { + packetizer: FramePacketizer::PYTHON_COMPATIBLE, + }; + + pub fn new(frames_per_packet: usize) -> Result { + Ok(Self { + packetizer: FramePacketizer::new(frames_per_packet)?, + }) + } + + pub const fn packetizer(&self) -> FramePacketizer { + self.packetizer + } + + pub fn pack_frames( + self, + link: &Link, + frames: impl IntoIterator, + ) -> Result, Error> { + self.packetizer + .packetize(frames) + .iter() + .map(|packet| pack_lxst_link_packet(link, packet)) + .collect() + } + + pub fn pack_raw_frames( + self, + link: &Link, + bit_depth: RawBitDepth, + frames: impl IntoIterator, + ) -> Result, Error> { + let frames = frames + .into_iter() + .map(|frame| frame.to_frame(bit_depth)) + .collect::, _>>()?; + self.pack_frames(link, frames) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LxstMediaIngress { + link_ingress: LxstLinkIngress, + jitter: JitterBuffer, +} + +impl LxstMediaIngress { + pub fn new(jitter_capacity: usize, drop_policy: DropPolicy) -> Result { + Ok(Self { + link_ingress: LxstLinkIngress::new(), + jitter: JitterBuffer::new(jitter_capacity, drop_policy)?, + }) + } + + pub const fn current_codec(&self) -> Option { + self.link_ingress.current_codec() + } + + pub const fn jitter_stats(&self) -> JitterStats { + self.jitter.stats() + } + + pub fn jitter_len(&self) -> usize { + self.jitter.len() + } + + pub fn accept_plaintext( + &mut self, + link_id: [u8; 16], + payload: &[u8], + ) -> Result { + let inbound = self.link_ingress.accept_plaintext(link_id, payload)?; + let mut jitter_pushes = Vec::new(); + + for event in &inbound.frame_events { + if let FrameStreamEvent::Frame(frame) = event { + jitter_pushes.push(self.jitter.push(frame.clone())); + } + } + + Ok(MediaIngressResult { + inbound, + jitter_pushes, + }) + } + + pub fn pop_frame(&mut self) -> Option { + self.jitter.pop() + } + + pub fn pop_raw_frame(&mut self) -> Result, Error> { + self.pop_frame() + .map(|frame| RawAudioFrame::from_frame(&frame).map_err(Error::from)) + .transpose() + } + + pub fn pop_opus_frame( + &mut self, + decoder: &mut OpusDecoderState, + ) -> Result, Error> { + self.pop_frame() + .map(|frame| decoder.decode_frame(&frame).map_err(Error::from)) + .transpose() + } +} + +/// Encode and encrypt an LXST packet for transmission over an active Reticulum +/// link as a no-receipt data packet. +pub fn pack_lxst_link_packet(link: &Link, packet: &LxstPacket) -> Result { + let payload = packet.encode()?; + pack_link_payload(link, &payload) +} + +/// Encrypt application payload bytes and wrap them in a Reticulum LINK/DATA +/// packet with context NONE, matching Python `RNS.Packet(link, data, +/// create_receipt=False)`. +pub fn pack_link_payload(link: &Link, payload: &[u8]) -> Result { + if link.state != LinkState::Active { + return Err(Error::LinkNotActive(link.state)); + } + + if payload.len() > link.mdu { + return Err(Error::PayloadExceedsMdu { + payload_len: payload.len(), + mdu: link.mdu, + }); + } + + let encrypted = link + .encrypt(payload) + .map_err(|e| Error::LinkEncrypt(e.to_string()))?; + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link.link_id, + context: rns_wire::context::PacketContext::None, + }; + + let mut raw = header.pack(); + raw.extend_from_slice(&encrypted); + let packet_hash = rns_wire::hash::packet_hash(&raw, rns_wire::flags::HeaderType::Header1); + + Ok(PackedLinkPacket { + raw: Bytes::from(raw), + packet_hash, + destination_hash: link.link_id, + }) +} + +/// Pack and queue an already-encoded LXST payload for Reticulum transmission. +pub fn queue_link_payload( + transport_tx: &mpsc::Sender, + link: &Link, + payload: &[u8], +) -> Result { + let packet = pack_link_payload(link, payload)?; + queue_packed_link_packet(transport_tx, packet) +} + +/// Pack and queue a structured LXST packet for Reticulum transmission. +pub fn queue_lxst_link_packet( + transport_tx: &mpsc::Sender, + link: &Link, + packet: &LxstPacket, +) -> Result { + let packet = pack_lxst_link_packet(link, packet)?; + queue_packed_link_packet(transport_tx, packet) +} + +fn queue_packed_link_packet( + transport_tx: &mpsc::Sender, + packet: PackedLinkPacket, +) -> Result { + transport_tx + .try_send(TransportMessage::Outbound(OutboundRequest { + raw: packet.raw.clone(), + destination_hash: packet.destination_hash, + })) + .map_err(|e| match e { + mpsc::error::TrySendError::Full(_) => Error::TransportFull, + mpsc::error::TrySendError::Closed(_) => Error::TransportClosed, + })?; + + Ok(packet) +} + +#[cfg(test)] +mod tests { + use super::*; + use lxst_core::{ + CodecKind, DropPolicy, Frame, FrameStreamEvent, OpusDecoderState, OpusEncoderState, + Profile, Signal, SignallingStatus, SyntheticSource, SyntheticSourceKind, + }; + use rns_crypto::ed25519::Ed25519PrivateKey; + + fn active_link_pair() -> (Link, Link) { + let dest_hash = [0xAA; 16]; + let identity_key = Ed25519PrivateKey::generate(); + let identity_pub = identity_key.public_key(); + + let (mut initiator, request_data) = Link::new_initiator(dest_hash, 1); + let (mut responder, proof_data) = + Link::new_responder(&request_data, &identity_key, dest_hash, 1).unwrap(); + + let rtt_data = initiator + .validate_proof(&proof_data, &identity_pub, &identity_pub.to_bytes()) + .unwrap(); + responder.receive_rtt_packet(&rtt_data).unwrap(); + + (initiator, responder) + } + + #[test] + fn packed_lxst_packet_decrypts_on_peer_link() { + let (initiator, responder) = active_link_pair(); + let lxst = LxstPacket::signalling([ + Signal::from(SignallingStatus::Available), + Signal::from(Profile::QualityMedium), + ]); + + let packet = pack_lxst_link_packet(&initiator, &lxst).unwrap(); + let (header, data_offset) = rns_wire::header::PacketHeader::unpack(&packet.raw).unwrap(); + + assert_eq!( + header.flags.destination_type, + rns_wire::flags::DestinationType::Link + ); + assert_eq!(header.flags.packet_type, rns_wire::flags::PacketType::Data); + assert_eq!(header.context, rns_wire::context::PacketContext::None); + assert_eq!(header.destination_hash, initiator.link_id); + + let decrypted = responder.decrypt(&packet.raw[data_offset..]).unwrap(); + assert_eq!(LxstPacket::decode(&decrypted).unwrap(), lxst); + } + + #[test] + fn queue_sends_outbound_transport_message() { + let (initiator, _responder) = active_link_pair(); + let payload = LxstPacket::frame(Frame::new(CodecKind::Raw, [0x00, 0x11, 0x22])) + .encode() + .unwrap(); + let (tx, mut rx) = mpsc::channel(1); + + let packet = queue_link_payload(&tx, &initiator, &payload).unwrap(); + let sent = rx.try_recv().unwrap(); + let TransportMessage::Outbound(outbound) = sent else { + panic!("expected outbound transport message"); + }; + + assert_eq!(outbound.raw, packet.raw); + assert_eq!(outbound.destination_hash, initiator.link_id); + } + + #[test] + fn inactive_links_do_not_pack_media_packets() { + let (link, _request_data) = Link::new_initiator([0xBB; 16], 1); + let packet = LxstPacket::signalling([Signal::from(SignallingStatus::Calling)]); + + assert!(matches!( + pack_lxst_link_packet(&link, &packet), + Err(Error::LinkNotActive(LinkState::Pending)) + )); + } + + #[test] + fn payloads_must_fit_link_mdu() { + let (initiator, _responder) = active_link_pair(); + let payload = vec![0u8; initiator.mdu + 1]; + + assert!(matches!( + pack_link_payload(&initiator, &payload), + Err(Error::PayloadExceedsMdu { .. }) + )); + } + + #[test] + fn ingress_decodes_decrypted_link_payloads_and_tracks_codecs() { + let link_id = [0x44; 16]; + let mut ingress = LxstLinkIngress::new(); + let packet = LxstPacket { + signals: vec![Signal::from(SignallingStatus::Established)], + frames: vec![ + Frame::new(CodecKind::Raw, [0x00, 0x11]), + Frame::new(CodecKind::Opus, [0xF8, 0xFF, 0xFE]), + ], + }; + + let inbound = ingress + .accept_plaintext(link_id, &packet.encode().unwrap()) + .unwrap(); + + assert_eq!(inbound.link_id, link_id); + assert_eq!(inbound.packet.signals, packet.signals); + assert_eq!( + inbound.frame_events, + vec![ + FrameStreamEvent::CodecChanged { + from: None, + to: CodecKind::Raw, + }, + FrameStreamEvent::Frame(Frame::new(CodecKind::Raw, [0x00, 0x11])), + FrameStreamEvent::CodecChanged { + from: Some(CodecKind::Raw), + to: CodecKind::Opus, + }, + FrameStreamEvent::Frame(Frame::new(CodecKind::Opus, [0xF8, 0xFF, 0xFE])), + ] + ); + assert_eq!(ingress.current_codec(), Some(CodecKind::Opus)); + } + + #[test] + fn ingress_rejects_malformed_plaintext() { + let mut ingress = LxstLinkIngress::new(); + assert!(matches!( + ingress.accept_plaintext([0x55; 16], b"not msgpack"), + Err(Error::Lxst(_)) + )); + } + + #[test] + fn media_egress_and_ingress_roundtrip_raw_frames_over_link_crypto() { + let (initiator, responder) = active_link_pair(); + let raw_frames = vec![ + RawAudioFrame::new(2, vec![0.0, 0.5, -0.25, 1.0]).unwrap(), + RawAudioFrame::new(2, vec![0.125, -0.125, 0.75, -0.75]).unwrap(), + ]; + + let packed = LxstMediaEgress::PYTHON_COMPATIBLE + .pack_raw_frames(&initiator, RawBitDepth::Float16, raw_frames.clone()) + .unwrap(); + assert_eq!(packed.len(), raw_frames.len()); + + let mut ingress = LxstMediaIngress::new(4, DropPolicy::DropOldest).unwrap(); + for packet in packed { + let (_header, data_offset) = rns_wire::header::PacketHeader::unpack(&packet.raw) + .expect("packed Reticulum header"); + let plaintext = responder.decrypt(&packet.raw[data_offset..]).unwrap(); + let result = ingress + .accept_plaintext(responder.link_id, &plaintext) + .expect("accept LXST payload"); + assert_eq!(result.jitter_pushes, vec![JitterPush::Accepted]); + } + + assert_eq!(ingress.current_codec(), Some(CodecKind::Raw)); + assert_eq!(ingress.jitter_len(), raw_frames.len()); + assert_eq!( + ingress.pop_raw_frame().unwrap(), + Some(raw_frames[0].clone()) + ); + assert_eq!( + ingress.pop_raw_frame().unwrap(), + Some(raw_frames[1].clone()) + ); + assert_eq!(ingress.pop_raw_frame().unwrap(), None); + assert_eq!( + ingress.jitter_stats(), + JitterStats { + pushed: 2, + popped: 2, + dropped_oldest: 0, + dropped_newest: 0, + underruns: 1, + } + ); + } + + #[test] + fn media_egress_can_batch_frames_for_rust_peers() { + let (initiator, responder) = active_link_pair(); + let raw_frames = vec![ + RawAudioFrame::new(1, vec![0.0]).unwrap(), + RawAudioFrame::new(1, vec![0.5]).unwrap(), + RawAudioFrame::new(1, vec![1.0]).unwrap(), + ]; + + let packed = LxstMediaEgress::new(2) + .unwrap() + .pack_raw_frames(&initiator, RawBitDepth::Float32, raw_frames.clone()) + .unwrap(); + assert_eq!(packed.len(), 2); + + let mut ingress = LxstMediaIngress::new(8, DropPolicy::DropNewest).unwrap(); + for packet in packed { + let (_header, data_offset) = rns_wire::header::PacketHeader::unpack(&packet.raw) + .expect("packed Reticulum header"); + let plaintext = responder.decrypt(&packet.raw[data_offset..]).unwrap(); + ingress + .accept_plaintext(responder.link_id, &plaintext) + .expect("accept LXST payload"); + } + + assert_eq!( + ingress.pop_raw_frame().unwrap(), + Some(raw_frames[0].clone()) + ); + assert_eq!( + ingress.pop_raw_frame().unwrap(), + Some(raw_frames[1].clone()) + ); + assert_eq!( + ingress.pop_raw_frame().unwrap(), + Some(raw_frames[2].clone()) + ); + } + + #[test] + fn media_ingress_jitter_policy_drops_oldest_under_pressure() { + let (initiator, responder) = active_link_pair(); + let raw_frames = vec![ + RawAudioFrame::new(1, vec![0.0]).unwrap(), + RawAudioFrame::new(1, vec![1.0]).unwrap(), + ]; + let packed = LxstMediaEgress::PYTHON_COMPATIBLE + .pack_raw_frames(&initiator, RawBitDepth::Float16, raw_frames.clone()) + .unwrap(); + let mut ingress = LxstMediaIngress::new(1, DropPolicy::DropOldest).unwrap(); + + let mut push_results = Vec::new(); + for packet in packed { + let (_header, data_offset) = rns_wire::header::PacketHeader::unpack(&packet.raw) + .expect("packed Reticulum header"); + let plaintext = responder.decrypt(&packet.raw[data_offset..]).unwrap(); + let result = ingress + .accept_plaintext(responder.link_id, &plaintext) + .unwrap(); + push_results.extend(result.jitter_pushes); + } + + assert!(matches!(push_results[0], JitterPush::Accepted)); + assert!(matches!(push_results[1], JitterPush::DroppedOldest(_))); + assert_eq!( + ingress.pop_raw_frame().unwrap(), + Some(raw_frames[1].clone()) + ); + assert_eq!( + ingress.jitter_stats(), + JitterStats { + pushed: 2, + popped: 1, + dropped_oldest: 1, + dropped_newest: 0, + underruns: 0, + } + ); + } + + #[test] + fn synthetic_raw_source_survives_link_media_flow() { + let (initiator, responder) = active_link_pair(); + let mut source = SyntheticSource::new( + 2, + 48_000, + 4, + SyntheticSourceKind::Ramp { + start: -0.5, + step: 0.125, + }, + ) + .unwrap(); + let expected = (0..12) + .map(|_| source.next_raw_frame().unwrap()) + .collect::>(); + + let packed = LxstMediaEgress::PYTHON_COMPATIBLE + .pack_raw_frames(&initiator, RawBitDepth::Float32, expected.clone()) + .unwrap(); + let mut ingress = LxstMediaIngress::new(16, DropPolicy::DropOldest).unwrap(); + + for packet in packed { + let (_header, data_offset) = + rns_wire::header::PacketHeader::unpack(&packet.raw).unwrap(); + let plaintext = responder.decrypt(&packet.raw[data_offset..]).unwrap(); + ingress + .accept_plaintext(responder.link_id, &plaintext) + .unwrap(); + } + + let mut actual = Vec::new(); + while let Some(frame) = ingress.pop_raw_frame().unwrap() { + actual.push(frame); + } + + assert_eq!(actual, expected); + assert_eq!( + ingress.jitter_stats(), + JitterStats { + pushed: 12, + popped: 12, + dropped_oldest: 0, + dropped_newest: 0, + underruns: 1, + } + ); + } + + #[test] + fn sustained_opus_source_survives_link_media_flow() { + let (initiator, responder) = active_link_pair(); + let profile = Profile::QualityHigh; + let mut source = SyntheticSource::new( + profile.channels(), + profile.sample_rate_hz(), + profile.sample_frames_per_packet(), + SyntheticSourceKind::Sine { + frequency_hz: 440.0, + amplitude: 0.25, + }, + ) + .unwrap(); + let raw_frames = (0..12) + .map(|_| source.next_raw_frame().unwrap()) + .collect::>(); + let mut encoder = OpusEncoderState::new(profile).unwrap(); + let opus_frames = raw_frames + .iter() + .map(|frame| encoder.encode_frame(frame).unwrap()) + .collect::>(); + + let packed = LxstMediaEgress::PYTHON_COMPATIBLE + .pack_frames(&initiator, opus_frames) + .unwrap(); + assert_eq!(packed.len(), raw_frames.len()); + + let mut ingress = LxstMediaIngress::new(16, DropPolicy::DropOldest).unwrap(); + for packet in packed { + let (_header, data_offset) = + rns_wire::header::PacketHeader::unpack(&packet.raw).unwrap(); + let plaintext = responder.decrypt(&packet.raw[data_offset..]).unwrap(); + let result = ingress + .accept_plaintext(responder.link_id, &plaintext) + .unwrap(); + assert_eq!(result.jitter_pushes, vec![JitterPush::Accepted]); + } + + let mut decoder = OpusDecoderState::new(profile).unwrap(); + let mut decoded_frames = Vec::new(); + while let Some(frame) = ingress.pop_opus_frame(&mut decoder).unwrap() { + decoded_frames.push(frame); + } + + assert_eq!(decoded_frames.len(), raw_frames.len()); + assert_eq!(ingress.current_codec(), Some(CodecKind::Opus)); + for frame in decoded_frames { + assert_eq!(frame.channels, profile.channels()); + assert_eq!(frame.sample_frames(), profile.sample_frames_per_packet()); + } + assert_eq!( + ingress.jitter_stats(), + JitterStats { + pushed: 12, + popped: 12, + dropped_oldest: 0, + dropped_newest: 0, + underruns: 1, + } + ); + } +} diff --git a/crates/lxst-telephony/Cargo.toml b/crates/lxst-telephony/Cargo.toml new file mode 100644 index 0000000..01df927 --- /dev/null +++ b/crates/lxst-telephony/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "lxst-telephony" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +bytes.workspace = true +lxst-core.workspace = true +lxst-rns.workspace = true +rns-crypto.workspace = true +rns-identity.workspace = true +rns-link.workspace = true +rns-runtime.workspace = true +rns-transport.workspace = true +rns-wire.workspace = true +thiserror.workspace = true +tokio.workspace = true + +[dev-dependencies] +hex.workspace = true +rns-interface.workspace = true +serial_test.workspace = true +serde_json.workspace = true diff --git a/crates/lxst-telephony/src/lib.rs b/crates/lxst-telephony/src/lib.rs new file mode 100644 index 0000000..2b04f8f --- /dev/null +++ b/crates/lxst-telephony/src/lib.rs @@ -0,0 +1,2863 @@ +//! Telephony runtime core for LXST. +//! +//! This crate owns the deterministic call-control boundary between Reticulum +//! link events and the pure LXST signalling planner. It deliberately avoids +//! spawning tasks or touching audio devices so live runtime code can be a thin +//! adapter around tested protocol behavior. + +use std::collections::{HashMap, HashSet}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use bytes::Bytes; +use lxst_core::{ + CallRole, CodecKind, Frame, FrameStreamEvent, LxstPacket, OpusCodecError, OpusDecoderState, + OpusEncoderState, Profile, RawAudioFrame, RawBitDepth, Signal, SignallingStatus, + TELEPHONY_DESTINATION_NAME, TelephonyAction, TelephonyCall, +}; +use lxst_rns::{InboundLxstPacket, LxstLinkIngress, LxstMediaEgress, queue_lxst_link_packet}; +use rns_crypto::ed25519::{Ed25519PrivateKey, Ed25519PublicKey}; +use rns_identity::destination::{ + DestType, Destination, DestinationError, Direction, ProofStrategy, +}; +use rns_identity::identity::Identity; +use rns_identity::name_hash::name_hash; +use rns_link::link::{CloseReason, Link}; +use rns_runtime::link_manager::LinkManager; +use rns_transport::link_messages::DestinationEvent; +use rns_transport::messages::{ + AnnounceHandlerEvent, AnnounceRpcEntry, OutboundRequest, TransportMessage, TransportQuery, + TransportQueryResponse, +}; +use thiserror::Error; +use tokio::sync::mpsc; +use tokio::time::{Duration, Instant, timeout}; + +pub type LinkId = [u8; 16]; +pub type IdentityHash = [u8; 16]; + +pub const TELEPHONY_ANNOUNCE_INTERVAL: Duration = Duration::from_secs(60 * 60 * 3); +pub const TELEPHONY_STARTUP_ANNOUNCE_RETRY_INTERVAL: Duration = Duration::from_secs(5); +pub const TELEPHONY_STARTUP_ANNOUNCE_RETRIES: u8 = 6; + +#[derive(Debug, Error)] +pub enum Error { + #[error("Reticulum destination error: {0}")] + Destination(#[from] DestinationError), + #[error("LXST Reticulum boundary error: {0}")] + Rns(#[from] lxst_rns::Error), + #[error("LXST Opus codec error: {0}")] + Opus(#[from] OpusCodecError), + #[error("unknown LXST link")] + UnknownLink, + #[error("line is busy")] + LineBusy, + #[error("no active call")] + NoActiveCall, + #[error("active call is not established")] + CallNotEstablished, + #[error("requested media profile {requested:?} does not match active call profile {active:?}")] + MediaProfileMismatch { active: Profile, requested: Profile }, + #[error("active call is on a different link")] + WrongActiveLink, + #[error("local identity does not contain a signing key")] + NoSigningKey, + #[error("Reticulum transport queue is closed")] + TransportClosed, + #[error("Reticulum transport queue is full")] + TransportFull, + #[error("LXST telephony service event queue is closed")] + ServiceEventClosed, + #[error("LXST telephony service event queue is full")] + ServiceEventFull, + #[error("Reticulum transport query channel closed")] + TransportQueryClosed, + #[error("unexpected Reticulum transport query response")] + UnexpectedTransportQueryResponse, + #[error("Reticulum link operation failed: {0}")] + LinkOperation(String), + #[error("Reticulum link proof validation failed: {0}")] + LinkProofInvalid(String), + #[error("Reticulum link destination channel closed")] + LinkDestinationClosed, + #[error("unexpected Reticulum destination event for link")] + UnexpectedLinkEvent, + #[error("remote LXST telephony announce did not include a Reticulum public key")] + RemotePublicKeyMissing, + #[error("remote LXST telephony announce was not discovered before timeout")] + RemoteTelephonyPeerNotDiscovered, + #[error("remote LXST telephony Reticulum path was not discovered before timeout")] + RemotePathNotDiscovered, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteTelephonyPeer { + pub identity_hash: IdentityHash, + pub destination_hash: [u8; 16], + pub public_key: [u8; 64], + pub hops: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CallerAllowPolicy { + All, + None, + List(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallerAccessPolicy { + pub allowed: CallerAllowPolicy, + pub blocked: Vec, +} + +impl Default for CallerAccessPolicy { + fn default() -> Self { + Self { + allowed: CallerAllowPolicy::All, + blocked: Vec::new(), + } + } +} + +impl CallerAccessPolicy { + pub fn is_allowed(&self, identity_hash: &IdentityHash) -> bool { + if self.blocked.iter().any(|blocked| blocked == identity_hash) { + return false; + } + + match &self.allowed { + CallerAllowPolicy::All => true, + CallerAllowPolicy::None => false, + CallerAllowPolicy::List(allowed) => { + allowed.iter().any(|allowed| allowed == identity_hash) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TelephonyCommand { + SendSignal { + link_id: LinkId, + signal: Signal, + }, + IdentifyLocalIdentity { + link_id: LinkId, + }, + SelectProfile { + link_id: LinkId, + profile: Profile, + }, + SwitchProfile { + link_id: LinkId, + profile: Profile, + }, + PrepareDialingPipelines { + link_id: LinkId, + }, + ResetDialingPipelines { + link_id: LinkId, + }, + OpenAudioPipelines { + link_id: LinkId, + }, + StartAudioPipelines { + link_id: LinkId, + }, + StopAudioPipelines { + link_id: LinkId, + }, + StartDialTone { + link_id: LinkId, + }, + RingIncomingCall { + link_id: LinkId, + remote_identity: IdentityHash, + }, + CallTerminated { + link_id: LinkId, + reason: Option, + }, + TeardownLink { + link_id: LinkId, + }, + IgnoredSignal { + link_id: LinkId, + signal: Signal, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TelephonyCommandEffect { + Noop, + QueuedLinkPacket { + link_id: LinkId, + kind: QueuedLinkPacketKind, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueuedLinkPacketKind { + LxstData, + LinkIdentify, + LinkClose, +} + +impl TelephonyCommand { + pub const fn link_id(&self) -> LinkId { + match self { + Self::SendSignal { link_id, .. } + | Self::IdentifyLocalIdentity { link_id } + | Self::SelectProfile { link_id, .. } + | Self::SwitchProfile { link_id, .. } + | Self::PrepareDialingPipelines { link_id } + | Self::ResetDialingPipelines { link_id } + | Self::OpenAudioPipelines { link_id } + | Self::StartAudioPipelines { link_id } + | Self::StopAudioPipelines { link_id } + | Self::StartDialTone { link_id } + | Self::RingIncomingCall { link_id, .. } + | Self::CallTerminated { link_id, .. } + | Self::TeardownLink { link_id } + | Self::IgnoredSignal { link_id, .. } => *link_id, + } + } + + pub const fn signal(&self) -> Option { + match self { + Self::SendSignal { signal, .. } => Some(*signal), + _ => None, + } + } + + pub fn to_lxst_packet(&self) -> Option { + self.signal().map(signalling_packet) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TelephonyStep { + pub inbound: Option, + pub commands: Vec, +} + +impl TelephonyStep { + pub fn commands(commands: Vec) -> Self { + Self { + inbound: None, + commands, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TelephonyDriveStep { + pub step: TelephonyStep, + pub effects: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TelephonyLinkEvent { + LinkEstablished { + link_id: LinkId, + }, + IncomingLinkEstablished { + link_id: LinkId, + }, + OutgoingLinkEstablished { + link_id: LinkId, + }, + RemoteIdentified { + link_id: LinkId, + remote_identity: IdentityHash, + }, + LinkPacket { + link_id: LinkId, + plaintext: Vec, + }, + LinkClosed { + link_id: LinkId, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveCall { + pub link_id: LinkId, + pub remote_identity: IdentityHash, + pub call: TelephonyCall, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveCallSnapshot { + pub link_id: LinkId, + pub remote_identity: IdentityHash, + pub role: CallRole, + pub status: SignallingStatus, + pub profile: Option, + pub answered: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TelephonyRuntimeSnapshot { + pub external_busy: bool, + pub pending_link_count: usize, + pub active_call: Option, +} + +#[derive(Debug, Default)] +pub struct TelephonyRuntimeCore { + access: CallerAccessPolicy, + external_busy: bool, + pending_links: HashSet, + ingress: HashMap, + active_call: Option, +} + +impl TelephonyRuntimeCore { + pub fn new() -> Self { + Self::default() + } + + pub fn with_access_policy(access: CallerAccessPolicy) -> Self { + Self { + access, + ..Self::default() + } + } + + pub const fn access_policy(&self) -> &CallerAccessPolicy { + &self.access + } + + pub fn set_access_policy(&mut self, access: CallerAccessPolicy) { + self.access = access; + } + + pub const fn external_busy(&self) -> bool { + self.external_busy + } + + pub fn set_external_busy(&mut self, busy: bool) { + self.external_busy = busy; + } + + pub fn line_busy(&self) -> bool { + self.external_busy || self.active_call.is_some() + } + + pub fn active_call(&self) -> Option<&ActiveCall> { + self.active_call.as_ref() + } + + pub fn pending_link_count(&self) -> usize { + self.pending_links.len() + } + + pub fn snapshot(&self) -> TelephonyRuntimeSnapshot { + TelephonyRuntimeSnapshot { + external_busy: self.external_busy, + pending_link_count: self.pending_links.len(), + active_call: self.active_call.as_ref().map(|active| ActiveCallSnapshot { + link_id: active.link_id, + remote_identity: active.remote_identity, + role: active.call.role(), + status: active.call.status(), + profile: active.call.profile(), + answered: active.call.answered(), + }), + } + } + + pub fn incoming_link_established(&mut self, link_id: LinkId) -> Vec { + let actions = TelephonyCall::incoming_link_established(self.line_busy()); + if !self.line_busy() { + self.pending_links.insert(link_id); + self.ingress.entry(link_id).or_default(); + } + + self.commands_from_actions(link_id, None, actions) + } + + pub fn caller_identified( + &mut self, + link_id: LinkId, + remote_identity: IdentityHash, + ) -> Result, Error> { + if !self.pending_links.contains(&link_id) && !self.ingress.contains_key(&link_id) { + return Err(Error::UnknownLink); + } + + let allowed = self.access.is_allowed(&remote_identity); + let busy = self.line_busy(); + let mut call = TelephonyCall::incoming(); + let actions = call.caller_identified(busy, allowed); + + if busy || !allowed { + self.pending_links.remove(&link_id); + self.ingress.remove(&link_id); + return Ok(self.commands_from_actions(link_id, Some(remote_identity), actions)); + } + + self.pending_links.remove(&link_id); + self.active_call = Some(ActiveCall { + link_id, + remote_identity, + call, + }); + + Ok(self.commands_from_actions(link_id, Some(remote_identity), actions)) + } + + pub fn start_outgoing_call( + &mut self, + link_id: LinkId, + remote_identity: IdentityHash, + profile: Option, + ) -> Result<(), Error> { + if self.line_busy() { + return Err(Error::LineBusy); + } + + self.ingress.entry(link_id).or_default(); + self.active_call = Some(ActiveCall { + link_id, + remote_identity, + call: TelephonyCall::outgoing(profile), + }); + Ok(()) + } + + pub fn outgoing_link_established(&mut self, link_id: LinkId) -> Result<(), Error> { + let active = self.active_call.as_ref().ok_or(Error::NoActiveCall)?; + if active.link_id != link_id { + return Err(Error::WrongActiveLink); + } + self.ingress.entry(link_id).or_default(); + Ok(()) + } + + pub fn answer_active(&mut self) -> Result, Error> { + let active = self.active_call.as_mut().ok_or(Error::NoActiveCall)?; + let link_id = active.link_id; + let remote_identity = active.remote_identity; + let actions = active.call.answer(); + Ok(self.commands_from_actions(link_id, Some(remote_identity), actions)) + } + + pub fn switch_active_profile( + &mut self, + profile: Profile, + ) -> Result, Error> { + let active = self.active_call.as_mut().ok_or(Error::NoActiveCall)?; + if active.call.status() != SignallingStatus::Established { + return Err(Error::CallNotEstablished); + } + + let link_id = active.link_id; + let remote_identity = active.remote_identity; + let actions = active.call.switch_profile(profile); + Ok(self.commands_from_actions(link_id, Some(remote_identity), actions)) + } + + pub fn hangup_active(&mut self, ring_timeout: bool) -> Result, Error> { + let Some(mut active) = self.active_call.take() else { + return Err(Error::NoActiveCall); + }; + let link_id = active.link_id; + let remote_identity = active.remote_identity; + let mut commands = self.commands_from_actions( + link_id, + Some(remote_identity), + active.call.hangup(ring_timeout), + ); + commands.push(TelephonyCommand::StopAudioPipelines { link_id }); + commands.push(TelephonyCommand::CallTerminated { + link_id, + reason: None, + }); + self.pending_links.remove(&link_id); + self.ingress.remove(&link_id); + Ok(commands) + } + + pub fn shutdown(&mut self) -> Vec { + let mut commands = Vec::new(); + + if let Some(mut active) = self.active_call.take() { + let link_id = active.link_id; + let remote_identity = active.remote_identity; + commands.extend(self.commands_from_actions( + link_id, + Some(remote_identity), + active.call.hangup(false), + )); + commands.push(TelephonyCommand::StopAudioPipelines { link_id }); + commands.push(TelephonyCommand::CallTerminated { + link_id, + reason: None, + }); + self.pending_links.remove(&link_id); + self.ingress.remove(&link_id); + } + + for link_id in self.pending_links.drain().collect::>() { + self.ingress.remove(&link_id); + commands.push(TelephonyCommand::TeardownLink { link_id }); + } + self.ingress.clear(); + + commands + } + + pub fn link_closed(&mut self, link_id: LinkId) -> Vec { + self.pending_links.remove(&link_id); + self.ingress.remove(&link_id); + + if self + .active_call + .as_ref() + .is_some_and(|active| active.link_id == link_id) + { + self.active_call.take(); + vec![ + TelephonyCommand::StopAudioPipelines { link_id }, + TelephonyCommand::CallTerminated { + link_id, + reason: None, + }, + ] + } else { + Vec::new() + } + } + + pub fn accept_lxst_plaintext( + &mut self, + link_id: LinkId, + payload: &[u8], + ) -> Result { + let ingress = self.ingress.get_mut(&link_id).ok_or(Error::UnknownLink)?; + let inbound = ingress.accept_plaintext(link_id, payload)?; + let mut commands = Vec::new(); + + for signal in inbound.packet.signals.clone() { + commands.extend(self.receive_signal(link_id, signal)?); + } + + Ok(TelephonyStep { + inbound: Some(inbound), + commands, + }) + } + + pub fn accept_link_event(&mut self, event: TelephonyLinkEvent) -> Result { + match event { + TelephonyLinkEvent::LinkEstablished { link_id } => { + if self.active_call.as_ref().is_some_and(|active| { + active.link_id == link_id && active.call.role() == CallRole::Outgoing + }) { + self.outgoing_link_established(link_id)?; + Ok(TelephonyStep::commands(Vec::new())) + } else { + Ok(TelephonyStep::commands( + self.incoming_link_established(link_id), + )) + } + } + TelephonyLinkEvent::IncomingLinkEstablished { link_id } => Ok(TelephonyStep::commands( + self.incoming_link_established(link_id), + )), + TelephonyLinkEvent::OutgoingLinkEstablished { link_id } => { + self.outgoing_link_established(link_id)?; + Ok(TelephonyStep::commands(Vec::new())) + } + TelephonyLinkEvent::RemoteIdentified { + link_id, + remote_identity, + } => Ok(TelephonyStep::commands( + self.caller_identified(link_id, remote_identity)?, + )), + TelephonyLinkEvent::LinkPacket { link_id, plaintext } => { + self.accept_lxst_plaintext(link_id, &plaintext) + } + TelephonyLinkEvent::LinkClosed { link_id } => { + Ok(TelephonyStep::commands(self.link_closed(link_id))) + } + } + } + + pub fn receive_signal( + &mut self, + link_id: LinkId, + signal: Signal, + ) -> Result, Error> { + let active = self.active_call.as_mut().ok_or(Error::NoActiveCall)?; + if active.link_id != link_id { + return Err(Error::WrongActiveLink); + } + + let remote_identity = active.remote_identity; + let actions = active.call.receive_signal(signal); + let terminates = actions.iter().find_map(|action| match action { + TelephonyAction::Terminate(reason) => Some(*reason), + _ => None, + }); + let mut commands = self.commands_from_actions(link_id, Some(remote_identity), actions); + + if let Some(reason) = terminates { + commands.push(TelephonyCommand::StopAudioPipelines { link_id }); + commands.push(TelephonyCommand::TeardownLink { link_id }); + commands.push(TelephonyCommand::CallTerminated { link_id, reason }); + self.active_call.take(); + self.pending_links.remove(&link_id); + self.ingress.remove(&link_id); + } + + Ok(commands) + } + + fn commands_from_actions( + &self, + link_id: LinkId, + remote_identity: Option, + actions: Vec, + ) -> Vec { + actions + .into_iter() + .filter_map(|action| match action { + TelephonyAction::SendSignal(signal) => { + Some(TelephonyCommand::SendSignal { link_id, signal }) + } + TelephonyAction::IdentifyLocalIdentity => { + Some(TelephonyCommand::IdentifyLocalIdentity { link_id }) + } + TelephonyAction::SelectProfile(profile) => { + Some(TelephonyCommand::SelectProfile { link_id, profile }) + } + TelephonyAction::PrepareDialingPipelines => { + Some(TelephonyCommand::PrepareDialingPipelines { link_id }) + } + TelephonyAction::ResetDialingPipelines => { + Some(TelephonyCommand::ResetDialingPipelines { link_id }) + } + TelephonyAction::OpenAudioPipelines => { + Some(TelephonyCommand::OpenAudioPipelines { link_id }) + } + TelephonyAction::StartAudioPipelines => { + Some(TelephonyCommand::StartAudioPipelines { link_id }) + } + TelephonyAction::StartDialTone => Some(TelephonyCommand::StartDialTone { link_id }), + TelephonyAction::TeardownLink => Some(TelephonyCommand::TeardownLink { link_id }), + TelephonyAction::RingIncomingCall => { + remote_identity.map(|remote_identity| TelephonyCommand::RingIncomingCall { + link_id, + remote_identity, + }) + } + TelephonyAction::SwitchProfile(profile) => { + Some(TelephonyCommand::SwitchProfile { link_id, profile }) + } + TelephonyAction::IgnoreSignal(signal) => { + Some(TelephonyCommand::IgnoredSignal { link_id, signal }) + } + TelephonyAction::Terminate(_) => None, + }) + .collect() + } +} + +#[derive(Debug)] +pub enum TelephonyControl { + Announce, + Call { + remote_identity: IdentityHash, + profile: Option, + discovery_timeout: Duration, + }, + Answer, + Hangup { + ring_timeout: bool, + }, + SendRawFrames { + bit_depth: RawBitDepth, + frames: Vec, + }, + SendOpusFrames { + profile: Profile, + frames: Vec, + }, + StartOpusStream { + profile: Profile, + frames: mpsc::Receiver, + }, + StopOpusStream, + StartOpusReceiveStream { + frames: mpsc::Sender, + }, + StopOpusReceiveStream, + SwitchProfile { + profile: Profile, + }, + SetExternalBusy(bool), + SetAccessPolicy(CallerAccessPolicy), + Shutdown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OpusTransmitStreamStopReason { + Requested, + Replaced, + SourceClosed, + CallEnded, + ProfileChanged, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OpusReceiveStreamStopReason { + Requested, + Replaced, + SinkClosed, + CallEnded, + ProfileChanged, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TelephonyServiceEvent { + OutgoingCallStarted { + link_id: LinkId, + remote_identity: IdentityHash, + }, + IncomingCall { + link_id: LinkId, + remote_identity: IdentityHash, + }, + CallTerminated { + link_id: LinkId, + reason: Option, + }, + MediaSent { + link_id: LinkId, + frames: usize, + packets: usize, + }, + MediaReceived { + link_id: LinkId, + frames: usize, + }, + OpusFramesReceived { + link_id: LinkId, + profile: Profile, + frames: Vec, + }, + OpusTransmitStreamStarted { + link_id: LinkId, + profile: Profile, + }, + OpusTransmitStreamStopped { + link_id: LinkId, + profile: Profile, + reason: OpusTransmitStreamStopReason, + }, + OpusReceiveStreamStarted { + link_id: LinkId, + profile: Profile, + }, + OpusReceiveStreamStopped { + link_id: LinkId, + profile: Profile, + reason: OpusReceiveStreamStopReason, + }, + OpusReceiveStreamFrames { + link_id: LinkId, + profile: Profile, + frames: usize, + dropped: usize, + }, + Snapshot(TelephonyRuntimeSnapshot), + Drive(TelephonyDriveStep), + Error { + message: String, + }, + Stopped, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TelephonyServiceConfig { + pub poll_interval: Duration, + pub incoming_ring_timeout: Option, + pub outgoing_call_timeout: Option, + pub media_frames_per_tick: usize, + pub announce_on_start: bool, + pub announce_interval: Option, + pub startup_announce_retry_interval: Option, + pub startup_announce_retries: u8, +} + +impl Default for TelephonyServiceConfig { + fn default() -> Self { + Self { + poll_interval: Duration::from_millis(20), + incoming_ring_timeout: Some(Duration::from_secs(60)), + outgoing_call_timeout: Some(Duration::from_secs(70)), + media_frames_per_tick: 4, + announce_on_start: true, + announce_interval: Some(TELEPHONY_ANNOUNCE_INTERVAL), + startup_announce_retry_interval: Some(TELEPHONY_STARTUP_ANNOUNCE_RETRY_INTERVAL), + startup_announce_retries: TELEPHONY_STARTUP_ANNOUNCE_RETRIES, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TelephonyServiceChannelConfig { + pub control_capacity: usize, + pub event_capacity: usize, +} + +impl Default for TelephonyServiceChannelConfig { + fn default() -> Self { + Self { + control_capacity: 32, + event_capacity: 128, + } + } +} + +pub struct TelephonyServiceParts { + pub service: TelephonyService, + pub control_tx: mpsc::Sender, + pub event_rx: mpsc::Receiver, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TelephonyServiceTimeoutKind { + IncomingRing, + OutgoingCall, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TelephonyServiceTimeout { + link_id: LinkId, + kind: TelephonyServiceTimeoutKind, + expires_at: Instant, +} + +#[derive(Default)] +struct TelephonyServiceMedia { + opus_encoder: Option, + opus_encoder_generation: u64, + opus_decoder: Option, + opus_decoder_generation: u64, + opus_transmit_stream: Option, + opus_receive_stream: Option, +} + +struct ActiveOpusEncoder { + link_id: LinkId, + profile: Profile, + encoder: OpusEncoderState, +} + +struct ActiveOpusDecoder { + link_id: LinkId, + profile: Profile, + decoder: OpusDecoderState, +} + +struct ActiveOpusTransmitStream { + link_id: LinkId, + profile: Profile, + frames_rx: mpsc::Receiver, +} + +struct ActiveOpusReceiveStream { + link_id: LinkId, + profile: Profile, + frames_tx: mpsc::Sender, +} + +impl TelephonyServiceMedia { + fn clear_unless_established( + &mut self, + active: Option<&ActiveCallSnapshot>, + ) -> Vec { + let mut events = Vec::new(); + let Some(active) = active.filter(|active| active.status == SignallingStatus::Established) + else { + self.opus_encoder = None; + self.opus_decoder = None; + if let Some(stream) = self.opus_transmit_stream.take() { + events.push(TelephonyServiceEvent::OpusTransmitStreamStopped { + link_id: stream.link_id, + profile: stream.profile, + reason: OpusTransmitStreamStopReason::CallEnded, + }); + } + if let Some(stream) = self.opus_receive_stream.take() { + events.push(TelephonyServiceEvent::OpusReceiveStreamStopped { + link_id: stream.link_id, + profile: stream.profile, + reason: OpusReceiveStreamStopReason::CallEnded, + }); + } + return events; + }; + + if self + .opus_encoder + .as_ref() + .is_some_and(|encoder| encoder.link_id != active.link_id) + { + self.opus_encoder = None; + } + if self + .opus_decoder + .as_ref() + .is_some_and(|decoder| decoder.link_id != active.link_id) + { + self.opus_decoder = None; + } + if self.opus_transmit_stream.as_ref().is_some_and(|stream| { + stream.link_id != active.link_id || Some(stream.profile) != active.profile + }) { + if let Some(stream) = self.opus_transmit_stream.take() { + let reason = if stream.link_id == active.link_id { + OpusTransmitStreamStopReason::ProfileChanged + } else { + OpusTransmitStreamStopReason::CallEnded + }; + events.push(TelephonyServiceEvent::OpusTransmitStreamStopped { + link_id: stream.link_id, + profile: stream.profile, + reason, + }); + } + } + if self.opus_receive_stream.as_ref().is_some_and(|stream| { + stream.link_id != active.link_id || Some(stream.profile) != active.profile + }) { + if let Some(stream) = self.opus_receive_stream.take() { + let reason = if stream.link_id == active.link_id { + OpusReceiveStreamStopReason::ProfileChanged + } else { + OpusReceiveStreamStopReason::CallEnded + }; + events.push(TelephonyServiceEvent::OpusReceiveStreamStopped { + link_id: stream.link_id, + profile: stream.profile, + reason, + }); + } + } + events + } + + fn opus_encoder_for( + &mut self, + link_id: LinkId, + profile: Profile, + ) -> Result<&mut OpusEncoderState, OpusCodecError> { + let needs_new = self + .opus_encoder + .as_ref() + .is_none_or(|encoder| encoder.link_id != link_id || encoder.profile != profile); + + if needs_new { + self.opus_encoder_generation += 1; + self.opus_encoder = Some(ActiveOpusEncoder { + link_id, + profile, + encoder: OpusEncoderState::new(profile)?, + }); + } + + Ok(&mut self + .opus_encoder + .as_mut() + .expect("Opus encoder exists after creation") + .encoder) + } + + fn opus_decoder_for( + &mut self, + link_id: LinkId, + profile: Profile, + ) -> Result<&mut OpusDecoderState, OpusCodecError> { + let needs_new = self + .opus_decoder + .as_ref() + .is_none_or(|decoder| decoder.link_id != link_id || decoder.profile != profile); + + if needs_new { + self.opus_decoder_generation += 1; + self.opus_decoder = Some(ActiveOpusDecoder { + link_id, + profile, + decoder: OpusDecoderState::new(profile)?, + }); + } + + Ok(&mut self + .opus_decoder + .as_mut() + .expect("Opus decoder exists after creation") + .decoder) + } +} + +pub struct TelephonyService { + endpoint: TelephonyRnsEndpoint, + core: TelephonyRuntimeCore, + control_rx: mpsc::Receiver, + event_tx: mpsc::Sender, + config: TelephonyServiceConfig, + active_timeout: Option, + next_announce_at: Option, + startup_announce_retries_remaining: u8, + media: TelephonyServiceMedia, +} + +impl TelephonyService { + pub fn registered( + transport_tx: mpsc::Sender, + identity: &Identity, + ) -> Result { + Self::registered_with_config( + transport_tx, + identity, + TelephonyServiceConfig::default(), + TelephonyServiceChannelConfig::default(), + ) + } + + pub fn registered_with_config( + transport_tx: mpsc::Sender, + identity: &Identity, + config: TelephonyServiceConfig, + channels: TelephonyServiceChannelConfig, + ) -> Result { + let endpoint = TelephonyRnsEndpoint::register(transport_tx, identity)?; + let (control_tx, control_rx) = mpsc::channel(channels.control_capacity.max(1)); + let (event_tx, event_rx) = mpsc::channel(channels.event_capacity.max(1)); + let service = Self::with_config( + endpoint, + TelephonyRuntimeCore::new(), + control_rx, + event_tx, + config, + ); + + Ok(TelephonyServiceParts { + service, + control_tx, + event_rx, + }) + } + + pub fn new( + endpoint: TelephonyRnsEndpoint, + core: TelephonyRuntimeCore, + control_rx: mpsc::Receiver, + event_tx: mpsc::Sender, + ) -> Self { + Self::with_config( + endpoint, + core, + control_rx, + event_tx, + TelephonyServiceConfig::default(), + ) + } + + pub fn with_config( + endpoint: TelephonyRnsEndpoint, + core: TelephonyRuntimeCore, + control_rx: mpsc::Receiver, + event_tx: mpsc::Sender, + config: TelephonyServiceConfig, + ) -> Self { + let next_announce_at = if config.announce_on_start { + Some(Instant::now()) + } else { + config + .announce_interval + .map(|interval| Instant::now() + interval) + }; + let startup_announce_retries_remaining = + if config.announce_on_start && config.startup_announce_retry_interval.is_some() { + config.startup_announce_retries + } else { + 0 + }; + + Self { + endpoint, + core, + control_rx, + event_tx, + config, + active_timeout: None, + next_announce_at, + startup_announce_retries_remaining, + media: TelephonyServiceMedia::default(), + } + } + + pub async fn run(mut self) { + let mut interval = tokio::time::interval(self.config.poll_interval); + + loop { + tokio::select! { + control = self.control_rx.recv() => { + let Some(control) = control else { + break; + }; + if !self.handle_control(control).await { + break; + } + } + _ = interval.tick() => { + if !self.handle_due_announce().await { + break; + } + if !self.drive_ready().await { + break; + } + if !self.handle_due_timeout().await { + break; + } + if !self.pump_opus_stream().await { + break; + } + } + } + } + + self.shutdown().await; + self.deregister_destinations(); + let _ = self.event_tx.send(TelephonyServiceEvent::Stopped).await; + } + + async fn handle_due_announce(&mut self) -> bool { + let Some(next_announce_at) = self.next_announce_at else { + return true; + }; + let now = Instant::now(); + if now < next_announce_at { + return true; + } + + match self.endpoint.announce() { + Ok(()) => { + self.next_announce_at = if self.startup_announce_retries_remaining > 0 { + self.startup_announce_retries_remaining -= 1; + self.config + .startup_announce_retry_interval + .map(|interval| Instant::now() + interval) + } else { + self.config + .announce_interval + .map(|interval| Instant::now() + interval) + }; + true + } + Err(err) => { + let retry_interval = self.config.poll_interval.max(Duration::from_secs(1)); + self.next_announce_at = Some(Instant::now() + retry_interval); + emit_service_error(self.event_tx.clone(), err).await + } + } + } + + async fn shutdown(&mut self) { + let commands = self.core.shutdown(); + if !commands.is_empty() { + let _ = self.control_commands(Ok(commands)).await; + } + + let stream_events = self.media.clear_unless_established(None); + let _ = emit_service_events(self.event_tx.clone(), stream_events).await; + self.active_timeout = None; + } + + fn deregister_destinations(&self) { + for link_id in self + .endpoint + .outgoing_attempts + .keys() + .chain(self.endpoint.outgoing_links.keys()) + { + let _ = self.endpoint.deregister_link_destination(*link_id); + } + let _ = self.endpoint.deregister_destination(); + } + + async fn handle_control(&mut self, control: TelephonyControl) -> bool { + let result = match control { + TelephonyControl::Announce => { + let result = self.endpoint.announce(); + if result.is_ok() { + self.startup_announce_retries_remaining = 0; + self.next_announce_at = self + .config + .announce_interval + .map(|interval| Instant::now() + interval); + } + result + } + TelephonyControl::Call { + remote_identity, + profile, + discovery_timeout, + } => match self + .endpoint + .begin_outgoing_link(&mut self.core, remote_identity, profile, discovery_timeout) + .await + { + Ok(link_id) => { + let stream_events = self.refresh_active_timeout(); + if !emit_service_events(self.event_tx.clone(), stream_events).await { + return false; + } + if !emit_service_event( + self.event_tx.clone(), + TelephonyServiceEvent::OutgoingCallStarted { + link_id, + remote_identity, + }, + ) + .await + { + return false; + } + return emit_snapshot(self.event_tx.clone(), self.core.snapshot()).await; + } + Err(err) => Err(err), + }, + TelephonyControl::Answer => { + let commands = self.core.answer_active(); + self.control_commands(commands).await + } + TelephonyControl::Hangup { ring_timeout } => { + let commands = self.core.hangup_active(ring_timeout); + self.control_commands(commands).await + } + TelephonyControl::SendRawFrames { bit_depth, frames } => { + return match self.send_raw_frames(bit_depth, frames).await { + Ok(()) => true, + Err(err) => emit_service_error(self.event_tx.clone(), err).await, + }; + } + TelephonyControl::SendOpusFrames { profile, frames } => { + return match self.send_opus_frames(profile, frames).await { + Ok(()) => true, + Err(err) => emit_service_error(self.event_tx.clone(), err).await, + }; + } + TelephonyControl::StartOpusStream { profile, frames } => { + return match self.start_opus_stream(profile, frames).await { + Ok(()) => true, + Err(err) => emit_service_error(self.event_tx.clone(), err).await, + }; + } + TelephonyControl::StopOpusStream => { + return self + .stop_opus_stream(OpusTransmitStreamStopReason::Requested) + .await; + } + TelephonyControl::StartOpusReceiveStream { frames } => { + return match self.start_opus_receive_stream(frames).await { + Ok(()) => true, + Err(err) => emit_service_error(self.event_tx.clone(), err).await, + }; + } + TelephonyControl::StopOpusReceiveStream => { + return self + .stop_opus_receive_stream(OpusReceiveStreamStopReason::Requested) + .await; + } + TelephonyControl::SwitchProfile { profile } => { + let commands = self.core.switch_active_profile(profile); + self.control_commands(commands).await + } + TelephonyControl::SetExternalBusy(busy) => { + self.core.set_external_busy(busy); + let stream_events = self.refresh_active_timeout(); + if !emit_service_events(self.event_tx.clone(), stream_events).await { + return false; + } + return emit_snapshot(self.event_tx.clone(), self.core.snapshot()).await; + } + TelephonyControl::SetAccessPolicy(access) => { + self.core.set_access_policy(access); + Ok(()) + } + TelephonyControl::Shutdown => return false, + }; + + match result { + Ok(()) => true, + Err(err) => emit_service_error(self.event_tx.clone(), err).await, + } + } + + async fn start_opus_receive_stream( + &mut self, + frames_tx: mpsc::Sender, + ) -> Result<(), Error> { + let active = self.core.active_call().ok_or(Error::NoActiveCall)?; + if active.call.status() != SignallingStatus::Established { + return Err(Error::CallNotEstablished); + } + + let link_id = active.link_id; + let profile = active.call.profile().unwrap_or(Profile::DEFAULT); + self.stop_opus_receive_stream(OpusReceiveStreamStopReason::Replaced) + .await; + self.media.opus_receive_stream = Some(ActiveOpusReceiveStream { + link_id, + profile, + frames_tx, + }); + + if emit_service_event( + self.event_tx.clone(), + TelephonyServiceEvent::OpusReceiveStreamStarted { link_id, profile }, + ) + .await + { + Ok(()) + } else { + Err(Error::ServiceEventClosed) + } + } + + async fn stop_opus_receive_stream(&mut self, reason: OpusReceiveStreamStopReason) -> bool { + let Some(stream) = self.media.opus_receive_stream.take() else { + return true; + }; + emit_service_event( + self.event_tx.clone(), + TelephonyServiceEvent::OpusReceiveStreamStopped { + link_id: stream.link_id, + profile: stream.profile, + reason, + }, + ) + .await + } + + async fn start_opus_stream( + &mut self, + profile: Profile, + frames_rx: mpsc::Receiver, + ) -> Result<(), Error> { + let active = self.core.active_call().ok_or(Error::NoActiveCall)?; + if active.call.status() != SignallingStatus::Established { + return Err(Error::CallNotEstablished); + } + let active_profile = active.call.profile().unwrap_or(Profile::DEFAULT); + if profile != active_profile { + return Err(Error::MediaProfileMismatch { + active: active_profile, + requested: profile, + }); + } + + let link_id = active.link_id; + self.stop_opus_stream(OpusTransmitStreamStopReason::Replaced) + .await; + self.media.opus_transmit_stream = Some(ActiveOpusTransmitStream { + link_id, + profile, + frames_rx, + }); + + if emit_service_event( + self.event_tx.clone(), + TelephonyServiceEvent::OpusTransmitStreamStarted { link_id, profile }, + ) + .await + { + Ok(()) + } else { + Err(Error::ServiceEventClosed) + } + } + + async fn stop_opus_stream(&mut self, reason: OpusTransmitStreamStopReason) -> bool { + let Some(stream) = self.media.opus_transmit_stream.take() else { + return true; + }; + emit_service_event( + self.event_tx.clone(), + TelephonyServiceEvent::OpusTransmitStreamStopped { + link_id: stream.link_id, + profile: stream.profile, + reason, + }, + ) + .await + } + + async fn pump_opus_stream(&mut self) -> bool { + let Some((profile, frames, source_closed)) = self.drain_opus_stream_frames() else { + return true; + }; + + if !frames.is_empty() + && let Err(err) = self.send_opus_frames(profile, frames).await + { + self.media.opus_transmit_stream = None; + return emit_service_error(self.event_tx.clone(), err).await; + } + + if source_closed { + self.stop_opus_stream(OpusTransmitStreamStopReason::SourceClosed) + .await + } else { + true + } + } + + fn drain_opus_stream_frames(&mut self) -> Option<(Profile, Vec, bool)> { + let stream = self.media.opus_transmit_stream.as_mut()?; + let mut frames = Vec::new(); + let mut source_closed = false; + let max_frames = self.config.media_frames_per_tick.max(1); + + for _ in 0..max_frames { + match stream.frames_rx.try_recv() { + Ok(frame) => frames.push(frame), + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => { + source_closed = true; + break; + } + } + } + + if frames.is_empty() && !source_closed { + None + } else { + Some((stream.profile, frames, source_closed)) + } + } + + async fn control_commands( + &mut self, + commands: Result, Error>, + ) -> Result<(), Error> { + let commands = commands?; + let effects = self.endpoint.execute_commands(&commands)?; + let service_events = service_events_from_commands(&commands); + let step = TelephonyStep::commands(commands); + let stream_events = self.refresh_active_timeout(); + if !emit_service_event( + self.event_tx.clone(), + TelephonyServiceEvent::Drive(TelephonyDriveStep { step, effects }), + ) + .await + { + return Err(Error::ServiceEventClosed); + } + + for event in service_events { + if !emit_service_event(self.event_tx.clone(), event).await { + return Err(Error::ServiceEventClosed); + } + } + for event in stream_events { + if !emit_service_event(self.event_tx.clone(), event).await { + return Err(Error::ServiceEventClosed); + } + } + + if emit_snapshot(self.event_tx.clone(), self.core.snapshot()).await { + Ok(()) + } else { + Err(Error::ServiceEventClosed) + } + } + + async fn send_raw_frames( + &mut self, + bit_depth: RawBitDepth, + frames: Vec, + ) -> Result<(), Error> { + let active = self.core.active_call().ok_or(Error::NoActiveCall)?; + if active.call.status() != SignallingStatus::Established { + return Err(Error::CallNotEstablished); + } + + let link_id = active.link_id; + let frame_count = frames.len(); + let packet_count = self.endpoint.queue_raw_frames(link_id, bit_depth, frames)?; + if emit_service_event( + self.event_tx.clone(), + TelephonyServiceEvent::MediaSent { + link_id, + frames: frame_count, + packets: packet_count, + }, + ) + .await + { + Ok(()) + } else { + Err(Error::ServiceEventClosed) + } + } + + async fn send_opus_frames( + &mut self, + profile: Profile, + frames: Vec, + ) -> Result<(), Error> { + let active = self.core.active_call().ok_or(Error::NoActiveCall)?; + if active.call.status() != SignallingStatus::Established { + return Err(Error::CallNotEstablished); + } + let active_profile = active.call.profile().unwrap_or(Profile::DEFAULT); + if profile != active_profile { + return Err(Error::MediaProfileMismatch { + active: active_profile, + requested: profile, + }); + } + + let link_id = active.link_id; + let frame_count = frames.len(); + let encoded = { + let encoder = self.media.opus_encoder_for(link_id, profile)?; + frames + .iter() + .map(|frame| encoder.encode_frame(frame)) + .collect::, _>>()? + }; + let packet_count = self.endpoint.queue_frames(link_id, encoded)?; + if emit_service_event( + self.event_tx.clone(), + TelephonyServiceEvent::MediaSent { + link_id, + frames: frame_count, + packets: packet_count, + }, + ) + .await + { + Ok(()) + } else { + Err(Error::ServiceEventClosed) + } + } + + async fn drive_ready(&mut self) -> bool { + match self.endpoint.try_drive_ready(&mut self.core) { + Ok(steps) => { + let emit_snapshot_after_steps = !steps.is_empty(); + for step in steps { + let service_events = service_events_from_commands(&step.step.commands); + let media_received = media_received_event(&step.step); + let opus_received_events = match self.opus_received_events(&step.step) { + Ok(events) => events, + Err(err) => { + if !emit_service_error(self.event_tx.clone(), err).await { + return false; + } + Vec::new() + } + }; + if !emit_service_event( + self.event_tx.clone(), + TelephonyServiceEvent::Drive(step), + ) + .await + { + return false; + } + for event in service_events { + if !emit_service_event(self.event_tx.clone(), event).await { + return false; + } + } + if let Some(event) = media_received + && !emit_service_event(self.event_tx.clone(), event).await + { + return false; + } + for event in opus_received_events { + if !emit_service_event(self.event_tx.clone(), event).await { + return false; + } + } + } + if emit_snapshot_after_steps { + let stream_events = self.refresh_active_timeout(); + for event in stream_events { + if !emit_service_event(self.event_tx.clone(), event).await { + return false; + } + } + } + if emit_snapshot_after_steps + && !emit_snapshot(self.event_tx.clone(), self.core.snapshot()).await + { + return false; + } + true + } + Err(err) => emit_service_error(self.event_tx.clone(), err).await, + } + } + + fn opus_received_events( + &mut self, + step: &TelephonyStep, + ) -> Result, Error> { + let Some(inbound) = step.inbound.as_ref() else { + return Ok(Vec::new()); + }; + let opus_frames = inbound + .frame_events + .iter() + .filter_map(|event| match event { + FrameStreamEvent::Frame(frame) if frame.codec == CodecKind::Opus => Some(frame), + _ => None, + }) + .collect::>(); + if opus_frames.is_empty() { + return Ok(Vec::new()); + } + + let active = self.core.active_call().ok_or(Error::NoActiveCall)?; + if active.link_id != inbound.link_id { + return Err(Error::WrongActiveLink); + } + let link_id = active.link_id; + let profile = active.call.profile().unwrap_or(Profile::DEFAULT); + let decoded = { + let decoder = self.media.opus_decoder_for(active.link_id, profile)?; + opus_frames + .iter() + .map(|frame| decoder.decode_frame(frame)) + .collect::, _>>()? + }; + + let receive_stream_events = self.deliver_opus_receive_stream(link_id, profile, &decoded); + let mut events = Vec::with_capacity(1 + receive_stream_events.len()); + events.push(TelephonyServiceEvent::OpusFramesReceived { + link_id, + profile, + frames: decoded, + }); + events.extend(receive_stream_events); + Ok(events) + } + + fn deliver_opus_receive_stream( + &mut self, + link_id: LinkId, + profile: Profile, + frames: &[RawAudioFrame], + ) -> Vec { + let Some(stream) = self.media.opus_receive_stream.as_ref() else { + return Vec::new(); + }; + if stream.link_id != link_id || stream.profile != profile { + self.media.opus_receive_stream = None; + return Vec::new(); + } + + let mut delivered = 0; + let mut dropped = 0; + let mut sink_closed = false; + for frame in frames { + let Some(stream) = self.media.opus_receive_stream.as_ref() else { + break; + }; + match stream.frames_tx.try_send(frame.clone()) { + Ok(()) => delivered += 1, + Err(mpsc::error::TrySendError::Full(_)) => dropped += 1, + Err(mpsc::error::TrySendError::Closed(_)) => { + sink_closed = true; + break; + } + } + } + + let mut events = Vec::new(); + if delivered > 0 || dropped > 0 { + events.push(TelephonyServiceEvent::OpusReceiveStreamFrames { + link_id, + profile, + frames: delivered, + dropped, + }); + } + if sink_closed && let Some(stream) = self.media.opus_receive_stream.take() { + events.push(TelephonyServiceEvent::OpusReceiveStreamStopped { + link_id: stream.link_id, + profile: stream.profile, + reason: OpusReceiveStreamStopReason::SinkClosed, + }); + } + events + } + + fn refresh_active_timeout(&mut self) -> Vec { + let snapshot = self.core.snapshot(); + let stream_events = self + .media + .clear_unless_established(snapshot.active_call.as_ref()); + let Some(active) = snapshot.active_call else { + self.active_timeout = None; + return stream_events; + }; + + let desired = match (active.role, active.status) { + (CallRole::Incoming, SignallingStatus::Ringing) => self + .config + .incoming_ring_timeout + .map(|duration| (TelephonyServiceTimeoutKind::IncomingRing, duration)), + (CallRole::Outgoing, status) if status != SignallingStatus::Established => self + .config + .outgoing_call_timeout + .map(|duration| (TelephonyServiceTimeoutKind::OutgoingCall, duration)), + _ => None, + }; + + let Some((kind, duration)) = desired else { + self.active_timeout = None; + return stream_events; + }; + + if self + .active_timeout + .is_some_and(|timeout| timeout.link_id == active.link_id && timeout.kind == kind) + { + return stream_events; + } + + self.active_timeout = Some(TelephonyServiceTimeout { + link_id: active.link_id, + kind, + expires_at: Instant::now() + duration, + }); + stream_events + } + + async fn handle_due_timeout(&mut self) -> bool { + let Some(timeout_state) = self.active_timeout else { + return true; + }; + if Instant::now() < timeout_state.expires_at { + return true; + } + + let Some(active) = self.core.snapshot().active_call else { + self.active_timeout = None; + return true; + }; + if active.link_id != timeout_state.link_id { + let stream_events = self.refresh_active_timeout(); + if !emit_service_events(self.event_tx.clone(), stream_events).await { + return false; + } + return true; + } + + let should_timeout = match timeout_state.kind { + TelephonyServiceTimeoutKind::IncomingRing => { + active.role == CallRole::Incoming && active.status == SignallingStatus::Ringing + } + TelephonyServiceTimeoutKind::OutgoingCall => { + active.role == CallRole::Outgoing && active.status != SignallingStatus::Established + } + }; + if !should_timeout { + let stream_events = self.refresh_active_timeout(); + if !emit_service_events(self.event_tx.clone(), stream_events).await { + return false; + } + return true; + } + + self.active_timeout = None; + let commands = self + .core + .hangup_active(timeout_state.kind == TelephonyServiceTimeoutKind::IncomingRing); + match self.control_commands(commands).await { + Ok(()) => true, + Err(err) => emit_service_error(self.event_tx.clone(), err).await, + } + } +} + +fn service_events_from_commands(commands: &[TelephonyCommand]) -> Vec { + commands + .iter() + .filter_map(|command| match command { + TelephonyCommand::RingIncomingCall { + link_id, + remote_identity, + } => Some(TelephonyServiceEvent::IncomingCall { + link_id: *link_id, + remote_identity: *remote_identity, + }), + TelephonyCommand::CallTerminated { link_id, reason } => { + Some(TelephonyServiceEvent::CallTerminated { + link_id: *link_id, + reason: *reason, + }) + } + _ => None, + }) + .collect() +} + +fn media_received_event(step: &TelephonyStep) -> Option { + let inbound = step.inbound.as_ref()?; + let frames = inbound + .frame_events + .iter() + .filter(|event| matches!(event, FrameStreamEvent::Frame(_))) + .count(); + (frames > 0).then_some(TelephonyServiceEvent::MediaReceived { + link_id: inbound.link_id, + frames, + }) +} + +async fn emit_service_error(event_tx: mpsc::Sender, err: Error) -> bool { + emit_service_event( + event_tx, + TelephonyServiceEvent::Error { + message: err.to_string(), + }, + ) + .await +} + +async fn emit_snapshot( + event_tx: mpsc::Sender, + snapshot: TelephonyRuntimeSnapshot, +) -> bool { + emit_service_event(event_tx, TelephonyServiceEvent::Snapshot(snapshot)).await +} + +async fn emit_service_events( + event_tx: mpsc::Sender, + events: Vec, +) -> bool { + for event in events { + if !emit_service_event(event_tx.clone(), event).await { + return false; + } + } + true +} + +async fn emit_service_event( + event_tx: mpsc::Sender, + event: TelephonyServiceEvent, +) -> bool { + event_tx.send(event).await.is_ok() +} + +pub struct TelephonyRnsEndpoint { + pub destination_hash: [u8; 16], + pub manager: LinkManager, + pub link_established_rx: mpsc::Receiver, + pub link_identified_rx: mpsc::Receiver<(LinkId, IdentityHash)>, + pub link_packet_rx: mpsc::Receiver<(Vec, LinkId)>, + pub link_closed_rx: mpsc::Receiver, + transport_tx: mpsc::Sender, + identity_pub_key: [u8; 64], + identity_signing_key: Ed25519PrivateKey, + outgoing_attempts: HashMap, + outgoing_links: HashMap, +} + +struct OutgoingLinkAttempt { + link: Link, + remote_public_key: [u8; 64], + event_rx: mpsc::Receiver, +} + +struct OutgoingLinkState { + link: Link, + event_rx: mpsc::Receiver, +} + +impl TelephonyRnsEndpoint { + pub fn register( + transport_tx: mpsc::Sender, + identity: &Identity, + ) -> Result { + let manager_signing_key = identity.get_signing_key().ok_or(Error::NoSigningKey)?; + let identity_signing_key = identity.get_signing_key().ok_or(Error::NoSigningKey)?; + let identity_pub_key = identity.get_public_key(); + let destination_hash = telephony_destination_hash(&identity.hash); + let (destination_tx, destination_rx) = mpsc::channel::(256); + + try_send_transport( + &transport_tx, + TransportMessage::RegisterDestination { + hash: destination_hash, + app_name: TELEPHONY_DESTINATION_NAME.to_string(), + delivery_tx: Some(destination_tx), + }, + )?; + + let mut manager = LinkManager::with_destination( + transport_tx.clone(), + destination_rx, + identity, + TELEPHONY_DESTINATION_NAME, + manager_signing_key, + ); + let (established_tx, link_established_rx) = mpsc::channel(64); + let (identified_tx, link_identified_rx) = mpsc::channel(64); + let (packet_tx, link_packet_rx) = mpsc::channel(256); + let (closed_tx, link_closed_rx) = mpsc::channel(64); + manager.set_link_established_channel(established_tx); + manager.set_link_identified_channel(identified_tx); + manager.set_link_packet_channel(packet_tx); + manager.set_link_closed_channel(closed_tx); + + Ok(Self { + destination_hash, + manager, + link_established_rx, + link_identified_rx, + link_packet_rx, + link_closed_rx, + transport_tx, + identity_pub_key, + identity_signing_key, + outgoing_attempts: HashMap::new(), + outgoing_links: HashMap::new(), + }) + } + + pub fn request_path_to_identity(&self, identity_hash: &IdentityHash) -> Result<(), Error> { + try_send_transport( + &self.transport_tx, + TransportMessage::RequestPath { + destination_hash: telephony_destination_hash(identity_hash), + }, + ) + } + + pub fn deregister_destination(&self) -> Result<(), Error> { + try_send_transport( + &self.transport_tx, + TransportMessage::DeregisterDestination { + hash: self.destination_hash, + }, + ) + } + + pub fn announce(&self) -> Result<(), Error> { + let raw = self.announce_packet(); + try_send_transport( + &self.transport_tx, + TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: self.destination_hash, + }), + ) + } + + fn announce_packet(&self) -> Vec { + let announce_name_hash = name_hash(TELEPHONY_DESTINATION_NAME); + let random_bytes = rns_crypto::random::random_bytes(5); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let timestamp_bytes = timestamp.to_be_bytes(); + let mut random_hash = [0u8; 10]; + random_hash[..5].copy_from_slice(&random_bytes); + random_hash[5..].copy_from_slice(×tamp_bytes[3..8]); + + let mut signed_data = Vec::with_capacity(16 + 64 + 10 + 10); + signed_data.extend_from_slice(&self.destination_hash); + signed_data.extend_from_slice(&self.identity_pub_key); + signed_data.extend_from_slice(&announce_name_hash); + signed_data.extend_from_slice(&random_hash); + let signature = self.identity_signing_key.sign(&signed_data); + + let mut announce_data = Vec::with_capacity(64 + 10 + 10 + 64); + announce_data.extend_from_slice(&self.identity_pub_key); + announce_data.extend_from_slice(&announce_name_hash); + announce_data.extend_from_slice(&random_hash); + announce_data.extend_from_slice(&signature); + + let flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Single, + packet_type: rns_wire::flags::PacketType::Announce, + }; + let header = rns_wire::header::PacketHeader { + flags, + hops: 0, + transport_id: None, + destination_hash: self.destination_hash, + context: rns_wire::context::PacketContext::None, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&announce_data); + raw + } + + pub fn deregister_link_destination(&self, link_id: LinkId) -> Result<(), Error> { + try_send_transport( + &self.transport_tx, + TransportMessage::DeregisterDestination { hash: link_id }, + ) + } + + pub fn discover_remote_telephony_peer( + &self, + remote_identity: IdentityHash, + discovery_timeout: Duration, + ) -> impl std::future::Future> + Send + 'static + { + let transport_tx = self.transport_tx.clone(); + async move { + discover_remote_telephony_peer_on_transport( + transport_tx, + remote_identity, + discovery_timeout, + ) + .await + } + } + + pub fn await_path_to_identity( + &self, + remote_identity: IdentityHash, + path_timeout: Duration, + ) -> impl std::future::Future> + Send + 'static { + let transport_tx = self.transport_tx.clone(); + async move { + await_path_on_transport( + transport_tx, + telephony_destination_hash(&remote_identity), + path_timeout, + ) + .await + } + } + + pub async fn begin_outgoing_link( + &mut self, + core: &mut TelephonyRuntimeCore, + remote_identity: IdentityHash, + profile: Option, + discovery_timeout: Duration, + ) -> Result { + let peer = discover_remote_telephony_peer_on_transport( + self.transport_tx.clone(), + remote_identity, + discovery_timeout, + ) + .await?; + await_path_on_transport( + self.transport_tx.clone(), + peer.destination_hash, + discovery_timeout, + ) + .await?; + self.begin_outgoing_link_with_remote_pubkey( + core, + remote_identity, + peer.public_key, + profile, + peer.hops, + ) + } + + pub fn begin_outgoing_link_with_remote_pubkey( + &mut self, + core: &mut TelephonyRuntimeCore, + remote_identity: IdentityHash, + remote_public_key: [u8; 64], + profile: Option, + hops: u8, + ) -> Result { + let destination_hash = telephony_destination_hash(&remote_identity); + self.request_path_to_identity(&remote_identity)?; + + let (link, request_data) = Link::new_initiator(destination_hash, hops.max(1)); + let link_id = link.link_id; + core.start_outgoing_call(link_id, remote_identity, profile)?; + + let (event_tx, event_rx) = mpsc::channel(128); + try_send_transport( + &self.transport_tx, + TransportMessage::RegisterDestination { + hash: link_id, + app_name: TELEPHONY_DESTINATION_NAME.to_string(), + delivery_tx: Some(event_tx), + }, + )?; + + try_send_transport( + &self.transport_tx, + TransportMessage::Outbound(OutboundRequest { + raw: build_link_request_packet(destination_hash, &request_data), + destination_hash, + }), + )?; + + self.outgoing_attempts.insert( + link_id, + OutgoingLinkAttempt { + link, + remote_public_key, + event_rx, + }, + ); + + Ok(link_id) + } + + pub fn try_recv_link_event(&mut self) -> Result, Error> { + if let Ok(link_id) = self.link_established_rx.try_recv() { + return Ok(Some(TelephonyLinkEvent::LinkEstablished { link_id })); + } + if let Ok((link_id, remote_identity)) = self.link_identified_rx.try_recv() { + return Ok(Some(TelephonyLinkEvent::RemoteIdentified { + link_id, + remote_identity, + })); + } + if let Ok((plaintext, link_id)) = self.link_packet_rx.try_recv() { + return Ok(Some(TelephonyLinkEvent::LinkPacket { link_id, plaintext })); + } + if let Ok(link_id) = self.link_closed_rx.try_recv() { + return Ok(Some(TelephonyLinkEvent::LinkClosed { link_id })); + } + self.try_recv_outgoing_event() + } + + pub async fn recv_link_event(&mut self) -> Result, Error> { + if let Some(event) = self.try_recv_outgoing_event()? { + return Ok(Some(event)); + } + + tokio::select! { + event = self.link_established_rx.recv() => { + Ok(event.map(|link_id| TelephonyLinkEvent::LinkEstablished { link_id })) + } + event = self.link_identified_rx.recv() => { + Ok(event.map(|(link_id, remote_identity)| TelephonyLinkEvent::RemoteIdentified { + link_id, + remote_identity, + })) + } + event = self.link_packet_rx.recv() => { + Ok(event.map(|(plaintext, link_id)| TelephonyLinkEvent::LinkPacket { + link_id, + plaintext, + })) + } + event = self.link_closed_rx.recv() => { + Ok(event.map(|link_id| TelephonyLinkEvent::LinkClosed { link_id })) + } + } + } + + pub fn try_step( + &mut self, + core: &mut TelephonyRuntimeCore, + ) -> Result, Error> { + self.try_recv_link_event()? + .map(|event| core.accept_link_event(event)) + .transpose() + } + + pub async fn step( + &mut self, + core: &mut TelephonyRuntimeCore, + ) -> Result, Error> { + self.recv_link_event() + .await? + .map(|event| core.accept_link_event(event)) + .transpose() + } + + pub fn try_drive_once( + &mut self, + core: &mut TelephonyRuntimeCore, + ) -> Result, Error> { + let Some(step) = self.try_step(core)? else { + return Ok(None); + }; + let effects = self.execute_commands(&step.commands)?; + Ok(Some(TelephonyDriveStep { step, effects })) + } + + pub async fn drive_once( + &mut self, + core: &mut TelephonyRuntimeCore, + ) -> Result, Error> { + let Some(step) = self.step(core).await? else { + return Ok(None); + }; + let effects = self.execute_commands(&step.commands)?; + Ok(Some(TelephonyDriveStep { step, effects })) + } + + pub fn try_pump_reticulum(&mut self) -> bool { + self.manager.try_step() + } + + pub fn tick_reticulum(&mut self) { + self.manager.tick(); + } + + pub fn try_drive_ready( + &mut self, + core: &mut TelephonyRuntimeCore, + ) -> Result, Error> { + let mut driven = Vec::new(); + + loop { + let mut progressed = false; + while self.try_pump_reticulum() { + progressed = true; + } + while let Some(step) = self.try_drive_once(core)? { + driven.push(step); + progressed = true; + } + if !progressed { + return Ok(driven); + } + } + } + + pub fn queue_raw_frames( + &mut self, + link_id: LinkId, + bit_depth: RawBitDepth, + frames: impl IntoIterator, + ) -> Result { + let frames = frames.into_iter().collect::>(); + let packets = if let Some(link) = self.manager.get_link_mut(&link_id) { + LxstMediaEgress::PYTHON_COMPATIBLE.pack_raw_frames(link, bit_depth, frames)? + } else { + let link = self + .outgoing_links + .get_mut(&link_id) + .map(|state| &mut state.link) + .ok_or(Error::UnknownLink)?; + LxstMediaEgress::PYTHON_COMPATIBLE.pack_raw_frames(link, bit_depth, frames)? + }; + self.queue_packed_media_packets(packets) + } + + pub fn queue_frames( + &mut self, + link_id: LinkId, + frames: impl IntoIterator, + ) -> Result { + let frames = frames.into_iter().collect::>(); + let packets = if let Some(link) = self.manager.get_link_mut(&link_id) { + LxstMediaEgress::PYTHON_COMPATIBLE.pack_frames(link, frames)? + } else { + let link = self + .outgoing_links + .get_mut(&link_id) + .map(|state| &mut state.link) + .ok_or(Error::UnknownLink)?; + LxstMediaEgress::PYTHON_COMPATIBLE.pack_frames(link, frames)? + }; + self.queue_packed_media_packets(packets) + } + + fn queue_packed_media_packets( + &mut self, + packets: Vec, + ) -> Result { + let packet_count = packets.len(); + + for packet in packets { + try_send_transport( + &self.transport_tx, + TransportMessage::Outbound(OutboundRequest { + raw: packet.raw, + destination_hash: packet.destination_hash, + }), + )?; + } + + Ok(packet_count) + } + + pub fn execute_command( + &mut self, + command: &TelephonyCommand, + ) -> Result { + if !matches!( + command, + TelephonyCommand::SendSignal { .. } + | TelephonyCommand::IdentifyLocalIdentity { .. } + | TelephonyCommand::TeardownLink { .. } + ) { + return Ok(TelephonyCommandEffect::Noop); + } + + let link_id = command.link_id(); + if let Some(link) = self.manager.get_link_mut(&link_id) { + return execute_command_with_link( + &self.transport_tx, + &self.identity_pub_key, + &self.identity_signing_key, + link, + command, + ); + } + + if matches!(command, TelephonyCommand::TeardownLink { .. }) + && self.outgoing_attempts.remove(&link_id).is_some() + { + self.deregister_link_destination(link_id)?; + return Ok(TelephonyCommandEffect::Noop); + } + + let effect = { + let link = self + .outgoing_links + .get_mut(&link_id) + .map(|state| &mut state.link) + .ok_or(Error::UnknownLink)?; + execute_command_with_link( + &self.transport_tx, + &self.identity_pub_key, + &self.identity_signing_key, + link, + command, + )? + }; + + if matches!(command, TelephonyCommand::TeardownLink { .. }) { + self.outgoing_links.remove(&link_id); + self.deregister_link_destination(link_id)?; + } + + Ok(effect) + } + + pub fn execute_commands( + &mut self, + commands: &[TelephonyCommand], + ) -> Result, Error> { + commands + .iter() + .map(|command| self.execute_command(command)) + .collect() + } + + fn try_recv_outgoing_event(&mut self) -> Result, Error> { + let attempt_ids = self.outgoing_attempts.keys().copied().collect::>(); + for link_id in attempt_ids { + let Some(attempt) = self.outgoing_attempts.get_mut(&link_id) else { + continue; + }; + match attempt.event_rx.try_recv() { + Ok(event) => { + if let Some(event) = self.handle_outgoing_attempt_event(link_id, event)? { + return Ok(Some(event)); + } + } + Err(mpsc::error::TryRecvError::Empty) => {} + Err(mpsc::error::TryRecvError::Disconnected) => { + self.outgoing_attempts.remove(&link_id); + return Err(Error::LinkDestinationClosed); + } + } + } + + let active_ids = self.outgoing_links.keys().copied().collect::>(); + for link_id in active_ids { + let Some(state) = self.outgoing_links.get_mut(&link_id) else { + continue; + }; + match state.event_rx.try_recv() { + Ok(event) => { + return self.handle_outgoing_active_event(link_id, event); + } + Err(mpsc::error::TryRecvError::Empty) => {} + Err(mpsc::error::TryRecvError::Disconnected) => { + self.outgoing_links.remove(&link_id); + return Ok(Some(TelephonyLinkEvent::LinkClosed { link_id })); + } + } + } + + Ok(None) + } + + fn handle_outgoing_attempt_event( + &mut self, + link_id: LinkId, + event: DestinationEvent, + ) -> Result, Error> { + let raw = match event { + DestinationEvent::LinkClosed { link_id: closed_id } if closed_id == link_id => { + self.outgoing_attempts.remove(&link_id); + self.deregister_link_destination(link_id)?; + return Ok(Some(TelephonyLinkEvent::LinkClosed { link_id })); + } + DestinationEvent::InboundPacket { raw, .. } => raw, + DestinationEvent::AnnounceRequested(_) + | DestinationEvent::DeliveryProof { .. } + | DestinationEvent::LinkEstablished { .. } + | DestinationEvent::LinkRequest { .. } + | DestinationEvent::LinkClosed { .. } => return Ok(None), + }; + let (header, data_offset) = rns_wire::header::PacketHeader::unpack(&raw) + .map_err(|err| Error::LinkOperation(err.to_string()))?; + if header.destination_hash != link_id + || header.flags.packet_type != rns_wire::flags::PacketType::Proof + || raw.len() <= data_offset + { + return Ok(None); + } + + let mut attempt = self + .outgoing_attempts + .remove(&link_id) + .ok_or(Error::UnknownLink)?; + let mut identity_ed25519_pub = [0u8; 32]; + identity_ed25519_pub.copy_from_slice(&attempt.remote_public_key[32..64]); + let verify_key = Ed25519PublicKey::from_bytes(&identity_ed25519_pub) + .map_err(|err| Error::LinkProofInvalid(err.to_string()))?; + let rtt_data = attempt + .link + .validate_proof(&raw[data_offset..], &verify_key, &identity_ed25519_pub) + .map_err(|err| Error::LinkProofInvalid(format!("{err:?}")))?; + + queue_link_context_packet( + &self.transport_tx, + link_id, + rns_wire::context::PacketContext::Lrrtt, + rtt_data, + )?; + self.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: attempt.link, + event_rx: attempt.event_rx, + }, + ); + + Ok(Some(TelephonyLinkEvent::LinkEstablished { link_id })) + } + + fn handle_outgoing_active_event( + &mut self, + link_id: LinkId, + event: DestinationEvent, + ) -> Result, Error> { + let raw = match event { + DestinationEvent::LinkClosed { link_id: closed_id } if closed_id == link_id => { + self.outgoing_links.remove(&link_id); + self.deregister_link_destination(link_id)?; + return Ok(Some(TelephonyLinkEvent::LinkClosed { link_id })); + } + DestinationEvent::InboundPacket { raw, .. } => raw, + DestinationEvent::AnnounceRequested(_) + | DestinationEvent::DeliveryProof { .. } + | DestinationEvent::LinkEstablished { .. } + | DestinationEvent::LinkRequest { .. } + | DestinationEvent::LinkClosed { .. } => return Ok(None), + }; + let (header, data_offset) = rns_wire::header::PacketHeader::unpack(&raw) + .map_err(|err| Error::LinkOperation(err.to_string()))?; + if header.destination_hash != link_id || raw.len() < data_offset { + return Ok(None); + } + let body = &raw[data_offset..]; + let state = self + .outgoing_links + .get_mut(&link_id) + .ok_or(Error::UnknownLink)?; + + match header.context { + rns_wire::context::PacketContext::LinkClose => { + if state.link.receive_teardown(body) { + self.outgoing_links.remove(&link_id); + self.deregister_link_destination(link_id)?; + Ok(Some(TelephonyLinkEvent::LinkClosed { link_id })) + } else { + Ok(None) + } + } + rns_wire::context::PacketContext::None => { + let plaintext = state + .link + .decrypt(body) + .map_err(|err| Error::LinkOperation(err.to_string()))?; + Ok(Some(TelephonyLinkEvent::LinkPacket { link_id, plaintext })) + } + rns_wire::context::PacketContext::Keepalive => { + state.link.record_inbound(); + Ok(None) + } + _ => Ok(None), + } + } +} + +pub fn execute_command_with_link( + transport_tx: &mpsc::Sender, + identity_pub_key: &[u8; 64], + identity_signing_key: &Ed25519PrivateKey, + link: &mut Link, + command: &TelephonyCommand, +) -> Result { + match command { + TelephonyCommand::SendSignal { link_id, signal } => { + let packet = signalling_packet(*signal); + queue_lxst_link_packet(transport_tx, link, &packet)?; + Ok(TelephonyCommandEffect::QueuedLinkPacket { + link_id: *link_id, + kind: QueuedLinkPacketKind::LxstData, + }) + } + TelephonyCommand::IdentifyLocalIdentity { link_id } => { + let encrypted = link + .identify(identity_pub_key, identity_signing_key) + .map_err(|err| Error::LinkOperation(err.to_string()))?; + queue_link_context_packet( + transport_tx, + *link_id, + rns_wire::context::PacketContext::LinkIdentify, + encrypted, + )?; + Ok(TelephonyCommandEffect::QueuedLinkPacket { + link_id: *link_id, + kind: QueuedLinkPacketKind::LinkIdentify, + }) + } + TelephonyCommand::TeardownLink { link_id } => { + let reason = if link.is_initiator { + CloseReason::InitiatorClosed + } else { + CloseReason::DestinationClosed + }; + let Some(encrypted) = link.teardown(reason) else { + return Ok(TelephonyCommandEffect::Noop); + }; + queue_link_context_packet( + transport_tx, + *link_id, + rns_wire::context::PacketContext::LinkClose, + encrypted, + )?; + Ok(TelephonyCommandEffect::QueuedLinkPacket { + link_id: *link_id, + kind: QueuedLinkPacketKind::LinkClose, + }) + } + _ => Ok(TelephonyCommandEffect::Noop), + } +} + +fn queue_link_context_packet( + transport_tx: &mpsc::Sender, + link_id: LinkId, + context: rns_wire::context::PacketContext, + encrypted: Vec, +) -> Result<(), Error> { + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&encrypted); + try_send_transport( + transport_tx, + TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: link_id, + }), + ) +} + +fn build_link_request_packet(dest_hash: [u8; 16], request_data: &[u8]) -> Bytes { + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Single, + packet_type: rns_wire::flags::PacketType::LinkRequest, + }, + hops: 0, + transport_id: None, + destination_hash: dest_hash, + context: rns_wire::context::PacketContext::None, + }; + let mut raw = header.pack(); + raw.extend_from_slice(request_data); + Bytes::from(raw) +} + +async fn discover_remote_telephony_peer_on_transport( + transport_tx: mpsc::Sender, + remote_identity: IdentityHash, + discovery_timeout: Duration, +) -> Result { + let destination_hash = telephony_destination_hash(&remote_identity); + let aspect_filter = TELEPHONY_DESTINATION_NAME.to_string(); + let (announce_tx, mut announce_rx) = mpsc::channel(64); + + send_transport_async( + transport_tx.clone(), + TransportMessage::RegisterAnnounceHandler { + aspect_filter: Some(aspect_filter.clone()), + receive_path_responses: true, + callback_tx: announce_tx, + }, + ) + .await?; + + let result = async { + if let Some(peer) = + recent_telephony_peer(transport_tx.clone(), remote_identity, destination_hash).await? + { + send_transport_async( + transport_tx.clone(), + TransportMessage::RequestPath { destination_hash }, + ) + .await?; + return Ok(peer); + } + + drop_path_on_transport(transport_tx.clone(), destination_hash).await?; + send_transport_async( + transport_tx.clone(), + TransportMessage::RequestPath { destination_hash }, + ) + .await?; + + wait_for_telephony_announce( + &mut announce_rx, + remote_identity, + destination_hash, + discovery_timeout, + ) + .await + } + .await; + + let _ = transport_tx.try_send(TransportMessage::DeregisterAnnounceHandler { + aspect_filter: Some(aspect_filter), + }); + + result +} + +async fn recent_telephony_peer( + transport_tx: mpsc::Sender, + remote_identity: IdentityHash, + destination_hash: [u8; 16], +) -> Result, Error> { + let entries = query_recent_announces(transport_tx).await?; + for entry in entries { + if entry.dest_hash == destination_hash { + if entry.public_key.is_none() { + continue; + } + return announce_entry_to_peer(remote_identity, destination_hash, entry).map(Some); + } + } + Ok(None) +} + +async fn query_recent_announces( + transport_tx: mpsc::Sender, +) -> Result, Error> { + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + send_transport_async( + transport_tx, + TransportMessage::Rpc { + query: TransportQuery::GetRecentAnnounces, + response_tx, + }, + ) + .await?; + + match response_rx.await.map_err(|_| Error::TransportQueryClosed)? { + TransportQueryResponse::Announces(entries) => Ok(entries), + TransportQueryResponse::Error(_) => Err(Error::UnexpectedTransportQueryResponse), + _ => Err(Error::UnexpectedTransportQueryResponse), + } +} + +async fn drop_path_on_transport( + transport_tx: mpsc::Sender, + destination_hash: [u8; 16], +) -> Result<(), Error> { + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + send_transport_async( + transport_tx, + TransportMessage::Rpc { + query: TransportQuery::DropPath { + dest: destination_hash, + }, + response_tx, + }, + ) + .await?; + + match response_rx.await.map_err(|_| Error::TransportQueryClosed)? { + TransportQueryResponse::Ok => Ok(()), + TransportQueryResponse::Error(_) => Err(Error::UnexpectedTransportQueryResponse), + _ => Err(Error::UnexpectedTransportQueryResponse), + } +} + +async fn await_path_on_transport( + transport_tx: mpsc::Sender, + destination_hash: [u8; 16], + path_timeout: Duration, +) -> Result<(), Error> { + let (reply, response) = tokio::sync::oneshot::channel(); + send_transport_async( + transport_tx, + TransportMessage::AwaitPath { + dest: destination_hash, + reply, + }, + ) + .await?; + + match timeout(path_timeout, response).await { + Ok(Ok(true)) => Ok(()), + Ok(Ok(false)) | Ok(Err(_)) | Err(_) => Err(Error::RemotePathNotDiscovered), + } +} + +async fn send_transport_async( + transport_tx: mpsc::Sender, + message: TransportMessage, +) -> Result<(), Error> { + transport_tx + .send(message) + .await + .map_err(|_| Error::TransportClosed) +} + +async fn wait_for_telephony_announce( + announce_rx: &mut mpsc::Receiver, + remote_identity: IdentityHash, + destination_hash: [u8; 16], + discovery_timeout: Duration, +) -> Result { + timeout(discovery_timeout, async { + while let Some(event) = announce_rx.recv().await { + if event.destination_hash == destination_hash { + match announce_event_to_peer(remote_identity, destination_hash, event) { + Ok(peer) => return Ok(peer), + Err(Error::RemotePublicKeyMissing) => continue, + Err(err) => return Err(err), + } + } + } + Err(Error::RemoteTelephonyPeerNotDiscovered) + }) + .await + .map_err(|_| Error::RemoteTelephonyPeerNotDiscovered)? +} + +fn announce_event_to_peer( + remote_identity: IdentityHash, + destination_hash: [u8; 16], + event: AnnounceHandlerEvent, +) -> Result { + let public_key = event.public_key.ok_or(Error::RemotePublicKeyMissing)?; + Ok(RemoteTelephonyPeer { + identity_hash: remote_identity, + destination_hash, + public_key, + hops: event.hops, + }) +} + +fn announce_entry_to_peer( + remote_identity: IdentityHash, + destination_hash: [u8; 16], + entry: AnnounceRpcEntry, +) -> Result { + let public_key = entry.public_key.ok_or(Error::RemotePublicKeyMissing)?; + Ok(RemoteTelephonyPeer { + identity_hash: remote_identity, + destination_hash, + public_key, + hops: entry.hops, + }) +} + +fn try_send_transport( + transport_tx: &mpsc::Sender, + message: TransportMessage, +) -> Result<(), Error> { + transport_tx.try_send(message).map_err(|err| match err { + mpsc::error::TrySendError::Full(_) => Error::TransportFull, + mpsc::error::TrySendError::Closed(_) => Error::TransportClosed, + }) +} + +pub fn telephony_destination_hash(identity_hash: &IdentityHash) -> [u8; 16] { + Destination::hash_from_name_and_identity(TELEPHONY_DESTINATION_NAME, Some(identity_hash)) +} + +pub fn telephony_inbound_destination(identity: &Identity) -> Result { + let mut destination = Destination::new( + Some(identity), + Direction::In, + DestType::Single, + TELEPHONY_DESTINATION_NAME, + )?; + destination.set_proof_strategy(ProofStrategy::ProveNone); + Ok(destination) +} + +pub fn signalling_packet(signal: Signal) -> LxstPacket { + LxstPacket::signalling([signal]) +} + +pub fn signalling_packets_from_commands( + commands: &[TelephonyCommand], +) -> Vec<(LinkId, LxstPacket)> { + commands + .iter() + .filter_map(|command| { + command + .to_lxst_packet() + .map(|packet| (command.link_id(), packet)) + }) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/lxst-telephony/src/tests.rs b/crates/lxst-telephony/src/tests.rs new file mode 100644 index 0000000..d06e720 --- /dev/null +++ b/crates/lxst-telephony/src/tests.rs @@ -0,0 +1,3147 @@ +use super::*; +use lxst_core::{ + CodecKind, OpusDecoderState, OpusEncoderState, SyntheticSource, SyntheticSourceKind, +}; +use rns_crypto::ed25519::Ed25519PrivateKey; +use rns_identity::announce::AnnounceData; +use rns_link::link::LinkState; +use rns_transport::messages::{ + AnnounceRpcEntry, TransportMessage, TransportQuery, TransportQueryResponse, +}; + +fn link(byte: u8) -> LinkId { + [byte; 16] +} + +fn identity(byte: u8) -> IdentityHash { + [byte; 16] +} + +fn announce_event( + destination_hash: [u8; 16], + hops: u8, + public_key: Option<[u8; 64]>, +) -> AnnounceHandlerEvent { + AnnounceHandlerEvent { + destination_hash, + identity_hash: None, + announce_packet_hash: [0; 32], + is_path_response: false, + hops, + app_data: None, + public_key, + ratchet: None, + name_hash: [0; 10], + } +} + +fn announce_entry( + destination_hash: [u8; 16], + hops: u8, + public_key: Option<[u8; 64]>, +) -> AnnounceRpcEntry { + AnnounceRpcEntry { + dest_hash: destination_hash, + hops, + app_data: None, + timestamp: 1234.0, + public_key, + ratchet: None, + retained: false, + } +} + +fn packet(signals: impl IntoIterator) -> Vec { + LxstPacket::signalling(signals).encode().unwrap() +} + +fn synthetic_frame_for_profile(profile: Profile) -> RawAudioFrame { + SyntheticSource::new( + profile.channels(), + profile.sample_rate_hz(), + profile.sample_frames_per_packet(), + SyntheticSourceKind::Sine { + frequency_hz: 440.0, + amplitude: 0.25, + }, + ) + .unwrap() + .next_raw_frame() + .unwrap() +} + +fn active_link_pair() -> (Link, Link) { + let dest_hash = [0xAA; 16]; + let identity_key = Ed25519PrivateKey::generate(); + let identity_pub = identity_key.public_key(); + + let (mut initiator, request_data) = Link::new_initiator(dest_hash, 1); + let (mut responder, proof_data) = + Link::new_responder(&request_data, &identity_key, dest_hash, 1).unwrap(); + + let rtt_data = initiator + .validate_proof(&proof_data, &identity_pub, &identity_pub.to_bytes()) + .unwrap(); + responder.receive_rtt_packet(&rtt_data).unwrap(); + + (initiator, responder) +} + +fn established_outgoing_service( + profile: Profile, + remote_identity_byte: u8, + event_capacity: usize, +) -> ( + TelephonyService, + Link, + LinkId, + mpsc::Sender, + mpsc::Receiver, +) { + let (service, receiver, link_id, link_event_tx, service_events, _transport_rx) = + established_outgoing_service_with_transport(profile, remote_identity_byte, event_capacity); + (service, receiver, link_id, link_event_tx, service_events) +} + +fn established_outgoing_service_with_transport( + profile: Profile, + remote_identity_byte: u8, + event_capacity: usize, +) -> ( + TelephonyService, + Link, + LinkId, + mpsc::Sender, + mpsc::Receiver, + mpsc::Receiver, +) { + let (sender, receiver) = active_link_pair(); + let link_id = sender.link_id; + let local_identity = Identity::new(); + let remote_identity = identity(remote_identity_byte); + + let (transport_tx, mut transport_rx) = mpsc::channel(32); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (link_event_tx, event_rx) = mpsc::channel(4); + endpoint.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: sender, + event_rx, + }, + ); + + let mut core = TelephonyRuntimeCore::new(); + core.start_outgoing_call(link_id, remote_identity, Some(profile)) + .unwrap(); + for status in [ + SignallingStatus::Available, + SignallingStatus::Ringing, + SignallingStatus::Connecting, + SignallingStatus::Established, + ] { + core.accept_lxst_plaintext(link_id, &packet([Signal::from(status)])) + .unwrap(); + } + + let (_control_tx, control_rx) = mpsc::channel(1); + let (event_tx, service_events) = mpsc::channel(event_capacity); + ( + TelephonyService::new(endpoint, core, control_rx, event_tx), + receiver, + link_id, + link_event_tx, + service_events, + transport_rx, + ) +} + +fn queue_inbound_opus_frame( + link_event_tx: &mpsc::Sender, + receiver: &Link, + link_id: LinkId, + encoder: &mut OpusEncoderState, + frame: RawAudioFrame, +) { + let opus_frame = encoder.encode_frame(&frame).unwrap(); + let plaintext = LxstPacket::frame(opus_frame).encode().unwrap(); + let encrypted = receiver.encrypt(&plaintext).unwrap(); + link_event_tx + .try_send(DestinationEvent::InboundPacket { + raw: Bytes::from(link_data_packet( + link_id, + rns_wire::context::PacketContext::None, + &encrypted, + )), + interface_id: 1, + }) + .unwrap(); +} + +fn take_outbound( + rx: &mut mpsc::Receiver, +) -> (rns_wire::header::PacketHeader, Vec) { + let message = rx.try_recv().unwrap(); + let TransportMessage::Outbound(outbound) = message else { + panic!("expected Outbound, got {message:?}"); + }; + let (header, data_offset) = rns_wire::header::PacketHeader::unpack(&outbound.raw).unwrap(); + (header, outbound.raw[data_offset..].to_vec()) +} + +fn collect_ready_service_events( + rx: &mut mpsc::Receiver, +) -> Vec { + let mut events = Vec::new(); + while let Ok(event) = rx.try_recv() { + events.push(event); + } + events +} + +fn assert_deregistered_link(rx: &mut mpsc::Receiver, link_id: LinkId) { + let deregister_link = rx.try_recv().unwrap(); + let TransportMessage::DeregisterDestination { + hash: deregistered_hash, + } = deregister_link + else { + panic!("expected DeregisterDestination, got {deregister_link:?}"); + }; + assert_eq!(deregistered_hash, link_id); +} + +fn assert_deregistered_destination( + rx: &mut mpsc::Receiver, + destination_hash: [u8; 16], +) { + let deregister = rx.try_recv().unwrap(); + let TransportMessage::DeregisterDestination { hash } = deregister else { + panic!("expected DeregisterDestination, got {deregister:?}"); + }; + assert_eq!(hash, destination_hash); +} + +fn link_proof_packet(link_id: LinkId, proof_data: &[u8]) -> Vec { + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Proof, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context: rns_wire::context::PacketContext::Lrproof, + }; + let mut raw = header.pack(); + raw.extend_from_slice(proof_data); + raw +} + +fn link_data_packet( + link_id: LinkId, + context: rns_wire::context::PacketContext, + body: &[u8], +) -> Vec { + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context, + }; + let mut raw = header.pack(); + raw.extend_from_slice(body); + raw +} + +#[test] +fn telephony_destination_uses_full_lxst_name() { + let identity = Identity::new(); + let destination = telephony_inbound_destination(&identity).unwrap(); + + assert_eq!(destination.app_name, TELEPHONY_DESTINATION_NAME); + assert_eq!(destination.hash, telephony_destination_hash(&identity.hash)); + assert_eq!(destination.proof_strategy, ProofStrategy::ProveNone); +} + +#[test] +fn caller_access_policy_matches_python_allow_and_block_order() { + let allowed = identity(0x11); + let blocked = identity(0x22); + let policy = CallerAccessPolicy { + allowed: CallerAllowPolicy::List(vec![allowed, blocked]), + blocked: vec![blocked], + }; + + assert!(policy.is_allowed(&allowed)); + assert!(!policy.is_allowed(&blocked)); + assert!(!policy.is_allowed(&identity(0x33))); + assert!( + !CallerAccessPolicy { + allowed: CallerAllowPolicy::None, + blocked: Vec::new(), + } + .is_allowed(&allowed) + ); +} + +#[test] +fn incoming_call_identify_answer_and_hangup_emit_python_ordered_commands() { + let mut core = TelephonyRuntimeCore::new(); + let link_id = link(0xAA); + let remote = identity(0x10); + + assert_eq!( + core.incoming_link_established(link_id), + vec![TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(SignallingStatus::Available), + }] + ); + assert_eq!(core.pending_link_count(), 1); + + assert_eq!( + core.caller_identified(link_id, remote).unwrap(), + vec![ + TelephonyCommand::ResetDialingPipelines { link_id }, + TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(SignallingStatus::Ringing), + }, + TelephonyCommand::RingIncomingCall { + link_id, + remote_identity: remote, + }, + ] + ); + assert_eq!(core.pending_link_count(), 0); + assert_eq!( + core.active_call().unwrap().call.status(), + SignallingStatus::Ringing + ); + + assert_eq!( + core.answer_active().unwrap(), + vec![ + TelephonyCommand::SelectProfile { + link_id, + profile: Profile::DEFAULT, + }, + TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(SignallingStatus::Connecting), + }, + TelephonyCommand::OpenAudioPipelines { link_id }, + TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(SignallingStatus::Established), + }, + TelephonyCommand::StartAudioPipelines { link_id }, + ] + ); + + assert_eq!( + core.hangup_active(false).unwrap(), + vec![ + TelephonyCommand::TeardownLink { link_id }, + TelephonyCommand::StopAudioPipelines { link_id }, + TelephonyCommand::CallTerminated { + link_id, + reason: None, + }, + ] + ); + assert!(core.active_call().is_none()); +} + +#[test] +fn blocked_or_busy_incoming_call_sends_busy_and_tears_down() { + let blocked = identity(0x44); + let mut core = TelephonyRuntimeCore::with_access_policy(CallerAccessPolicy { + allowed: CallerAllowPolicy::All, + blocked: vec![blocked], + }); + let link_id = link(0xBB); + + core.incoming_link_established(link_id); + assert_eq!( + core.caller_identified(link_id, blocked).unwrap(), + vec![ + TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(SignallingStatus::Busy), + }, + TelephonyCommand::TeardownLink { link_id }, + ] + ); + assert!(core.active_call().is_none()); + + core.start_outgoing_call(link(0xCC), identity(0x55), None) + .unwrap(); + assert_eq!( + core.incoming_link_established(link(0xDD)), + vec![ + TelephonyCommand::SendSignal { + link_id: link(0xDD), + signal: Signal::from(SignallingStatus::Busy), + }, + TelephonyCommand::TeardownLink { + link_id: link(0xDD) + }, + ] + ); +} + +#[test] +fn outgoing_signalling_from_lxst_packets_drives_call_setup() { + let mut core = TelephonyRuntimeCore::new(); + let link_id = link(0xCC); + core.start_outgoing_call(link_id, identity(0x77), Some(Profile::LatencyLow)) + .unwrap(); + + let available = core + .accept_lxst_plaintext( + link_id, + &packet([Signal::from(SignallingStatus::Available)]), + ) + .unwrap(); + assert_eq!( + available.commands, + vec![TelephonyCommand::IdentifyLocalIdentity { link_id }] + ); + + let ringing = core + .accept_lxst_plaintext(link_id, &packet([Signal::from(SignallingStatus::Ringing)])) + .unwrap(); + assert_eq!( + ringing.commands, + vec![ + TelephonyCommand::SelectProfile { + link_id, + profile: Profile::LatencyLow, + }, + TelephonyCommand::PrepareDialingPipelines { link_id }, + TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(Profile::LatencyLow), + }, + TelephonyCommand::StartDialTone { link_id }, + ] + ); + + let connecting = core + .accept_lxst_plaintext( + link_id, + &packet([Signal::from(SignallingStatus::Connecting)]), + ) + .unwrap(); + assert_eq!( + connecting.commands, + vec![ + TelephonyCommand::ResetDialingPipelines { link_id }, + TelephonyCommand::OpenAudioPipelines { link_id }, + ] + ); + + let established = core + .accept_lxst_plaintext( + link_id, + &packet([Signal::from(SignallingStatus::Established)]), + ) + .unwrap(); + assert_eq!( + established.commands, + vec![TelephonyCommand::StartAudioPipelines { link_id }] + ); + assert_eq!( + core.active_call().unwrap().call.status(), + SignallingStatus::Established + ); +} + +#[test] +fn local_profile_switch_updates_active_call_and_signals_remote() { + let mut core = TelephonyRuntimeCore::new(); + let link_id = link(0xC7); + core.start_outgoing_call(link_id, identity(0xC8), Some(Profile::QualityMedium)) + .unwrap(); + core.receive_signal(link_id, Signal::from(SignallingStatus::Established)) + .unwrap(); + + assert_eq!( + core.switch_active_profile(Profile::QualityHigh).unwrap(), + vec![ + TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(Profile::QualityHigh), + }, + TelephonyCommand::SwitchProfile { + link_id, + profile: Profile::QualityHigh, + }, + ] + ); + assert_eq!( + core.active_call().unwrap().call.profile(), + Some(Profile::QualityHigh) + ); + assert!( + core.switch_active_profile(Profile::QualityHigh) + .unwrap() + .is_empty() + ); +} + +#[test] +fn incoming_call_ignores_status_signalling_until_answered() { + let mut core = TelephonyRuntimeCore::new(); + let link_id = link(0xEE); + + core.incoming_link_established(link_id); + core.caller_identified(link_id, identity(0x88)).unwrap(); + + let step = core + .accept_lxst_plaintext( + link_id, + &packet([Signal::from(SignallingStatus::Established)]), + ) + .unwrap(); + assert_eq!( + step.commands, + vec![TelephonyCommand::IgnoredSignal { + link_id, + signal: Signal::from(SignallingStatus::Established), + }] + ); + assert_eq!( + core.active_call().unwrap().call.status(), + SignallingStatus::Ringing + ); +} + +#[test] +fn remote_busy_terminates_and_clears_active_call() { + let mut core = TelephonyRuntimeCore::new(); + let link_id = link(0xAB); + core.start_outgoing_call(link_id, identity(0x99), None) + .unwrap(); + + assert_eq!( + core.receive_signal(link_id, Signal::from(SignallingStatus::Busy)) + .unwrap(), + vec![ + TelephonyCommand::StopAudioPipelines { link_id }, + TelephonyCommand::TeardownLink { link_id }, + TelephonyCommand::CallTerminated { + link_id, + reason: Some(SignallingStatus::Busy), + }, + ] + ); + assert!(core.active_call().is_none()); + assert!(matches!( + core.receive_signal(link_id, Signal::from(SignallingStatus::Available)), + Err(Error::NoActiveCall) + )); +} + +#[test] +fn link_closed_stops_active_call_and_forgets_pending_links() { + let mut core = TelephonyRuntimeCore::new(); + let pending = link(0x01); + core.incoming_link_established(pending); + assert!(core.link_closed(pending).is_empty()); + assert_eq!(core.pending_link_count(), 0); + + let active = link(0x02); + core.start_outgoing_call(active, identity(0x02), None) + .unwrap(); + assert_eq!( + core.link_closed(active), + vec![ + TelephonyCommand::StopAudioPipelines { link_id: active }, + TelephonyCommand::CallTerminated { + link_id: active, + reason: None, + }, + ] + ); + assert!(core.active_call().is_none()); +} + +#[test] +fn runtime_snapshot_reports_busy_pending_and_active_call_state() { + let mut core = TelephonyRuntimeCore::new(); + let pending = link(0xA1); + core.set_external_busy(true); + core.incoming_link_established(pending); + + assert_eq!( + core.snapshot(), + TelephonyRuntimeSnapshot { + external_busy: true, + pending_link_count: 0, + active_call: None, + } + ); + + core.set_external_busy(false); + core.incoming_link_established(pending); + core.caller_identified(pending, identity(0xA2)).unwrap(); + assert_eq!( + core.snapshot(), + TelephonyRuntimeSnapshot { + external_busy: false, + pending_link_count: 0, + active_call: Some(ActiveCallSnapshot { + link_id: pending, + remote_identity: identity(0xA2), + role: CallRole::Incoming, + status: SignallingStatus::Ringing, + profile: None, + answered: false, + }), + } + ); +} + +#[test] +fn signalling_packet_helper_packs_single_signal() { + let encoded = signalling_packet(Signal::from(SignallingStatus::Available)) + .encode() + .unwrap(); + assert_eq!( + LxstPacket::decode(&encoded).unwrap().signals, + vec![Signal::from(SignallingStatus::Available)] + ); +} + +#[test] +fn send_signal_commands_convert_back_to_lxst_packets() { + let link_id = link(0xB1); + let commands = vec![ + TelephonyCommand::ResetDialingPipelines { link_id }, + TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(SignallingStatus::Ringing), + }, + TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(Profile::LatencyUltraLow), + }, + TelephonyCommand::StartDialTone { link_id }, + ]; + + let packets = signalling_packets_from_commands(&commands); + assert_eq!(packets.len(), 2); + assert_eq!(packets[0].0, link_id); + assert_eq!( + packets[0].1.signals, + vec![Signal::from(SignallingStatus::Ringing)] + ); + assert_eq!( + packets[1].1.signals, + vec![Signal::from(Profile::LatencyUltraLow)] + ); +} + +#[test] +fn service_events_report_incoming_calls_and_terminations() { + let link_id = link(0xB3); + let remote_identity = identity(0xB4); + assert_eq!( + service_events_from_commands(&[ + TelephonyCommand::RingIncomingCall { + link_id, + remote_identity, + }, + TelephonyCommand::CallTerminated { + link_id, + reason: Some(SignallingStatus::Rejected), + }, + TelephonyCommand::StopAudioPipelines { link_id }, + ]), + vec![ + TelephonyServiceEvent::IncomingCall { + link_id, + remote_identity, + }, + TelephonyServiceEvent::CallTerminated { + link_id, + reason: Some(SignallingStatus::Rejected), + }, + ] + ); +} + +#[test] +fn link_event_adapter_feeds_runtime_core() { + let mut core = TelephonyRuntimeCore::new(); + let link_id = link(0xF1); + let remote = identity(0xF2); + + assert_eq!( + core.accept_link_event(TelephonyLinkEvent::IncomingLinkEstablished { link_id }) + .unwrap() + .commands, + vec![TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(SignallingStatus::Available), + }] + ); + + assert_eq!( + core.accept_link_event(TelephonyLinkEvent::RemoteIdentified { + link_id, + remote_identity: remote, + }) + .unwrap() + .commands, + vec![ + TelephonyCommand::ResetDialingPipelines { link_id }, + TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(SignallingStatus::Ringing), + }, + TelephonyCommand::RingIncomingCall { + link_id, + remote_identity: remote, + }, + ] + ); + + assert_eq!( + core.accept_link_event(TelephonyLinkEvent::LinkClosed { link_id }) + .unwrap() + .commands, + vec![ + TelephonyCommand::StopAudioPipelines { link_id }, + TelephonyCommand::CallTerminated { + link_id, + reason: None, + }, + ] + ); +} + +#[test] +fn generic_link_established_event_uses_current_call_role() { + let mut core = TelephonyRuntimeCore::new(); + let outgoing_link = link(0xC1); + core.start_outgoing_call(outgoing_link, identity(0xC2), None) + .unwrap(); + + assert_eq!( + core.accept_link_event(TelephonyLinkEvent::LinkEstablished { + link_id: outgoing_link, + }) + .unwrap() + .commands, + Vec::new(), + ); + + let incoming_link = link(0xC3); + assert_eq!( + core.accept_link_event(TelephonyLinkEvent::LinkEstablished { + link_id: incoming_link, + }) + .unwrap() + .commands, + vec![ + TelephonyCommand::SendSignal { + link_id: incoming_link, + signal: Signal::from(SignallingStatus::Busy), + }, + TelephonyCommand::TeardownLink { + link_id: incoming_link, + }, + ] + ); +} + +#[test] +fn rns_endpoint_registers_lxst_telephony_destination_and_channels() { + let identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(1); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &identity).unwrap(); + + assert_eq!( + endpoint.destination_hash, + telephony_destination_hash(&identity.hash) + ); + assert!(endpoint.link_established_rx.try_recv().is_err()); + assert!(endpoint.link_identified_rx.try_recv().is_err()); + assert!(endpoint.link_packet_rx.try_recv().is_err()); + assert!(endpoint.link_closed_rx.try_recv().is_err()); + + let registered = transport_rx.try_recv().unwrap(); + let TransportMessage::RegisterDestination { + hash, + app_name, + delivery_tx: Some(_), + } = registered + else { + panic!("expected RegisterDestination, got {registered:?}"); + }; + + assert_eq!(hash, endpoint.destination_hash); + assert_eq!(app_name, TELEPHONY_DESTINATION_NAME); +} + +#[test] +fn rns_endpoint_try_step_feeds_established_and_packet_events() { + let local_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(1); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _registered = transport_rx.try_recv().unwrap(); + + let (established_tx, established_rx) = mpsc::channel(1); + endpoint + .manager + .set_link_established_channel(established_tx.clone()); + endpoint.link_established_rx = established_rx; + + let link_id = link(0xD1); + established_tx.try_send(link_id).unwrap(); + let mut core = TelephonyRuntimeCore::new(); + assert_eq!( + endpoint.try_step(&mut core).unwrap().unwrap().commands, + vec![TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(SignallingStatus::Available), + }] + ); + + let (packet_tx, packet_rx) = mpsc::channel(1); + endpoint.manager.set_link_packet_channel(packet_tx.clone()); + endpoint.link_packet_rx = packet_rx; + let remote = identity(0xD2); + core.caller_identified(link_id, remote).unwrap(); + packet_tx + .try_send(( + signalling_packet(Signal::from(Profile::LatencyLow)) + .encode() + .unwrap(), + link_id, + )) + .unwrap(); + + assert_eq!( + endpoint.try_step(&mut core).unwrap().unwrap().commands, + vec![TelephonyCommand::SelectProfile { + link_id, + profile: Profile::LatencyLow, + }] + ); + + let (closed_tx, closed_rx) = mpsc::channel(1); + endpoint.manager.set_link_closed_channel(closed_tx.clone()); + endpoint.link_closed_rx = closed_rx; + closed_tx.try_send(link_id).unwrap(); + assert_eq!( + endpoint.try_step(&mut core).unwrap().unwrap().commands, + vec![ + TelephonyCommand::StopAudioPipelines { link_id }, + TelephonyCommand::CallTerminated { + link_id, + reason: None, + } + ] + ); +} + +#[test] +fn rns_endpoint_try_drive_once_executes_step_commands() { + let local_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(1); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _registered = transport_rx.try_recv().unwrap(); + + let link_id = link(0xE1); + let mut core = TelephonyRuntimeCore::new(); + core.start_outgoing_call(link_id, identity(0xE2), None) + .unwrap(); + + let (packet_tx, packet_rx) = mpsc::channel(1); + endpoint.manager.set_link_packet_channel(packet_tx.clone()); + endpoint.link_packet_rx = packet_rx; + packet_tx + .try_send(( + signalling_packet(Signal::from(Profile::LatencyLow)) + .encode() + .unwrap(), + link_id, + )) + .unwrap(); + + let driven = endpoint.try_drive_once(&mut core).unwrap().unwrap(); + assert_eq!( + driven.step.commands, + vec![TelephonyCommand::SelectProfile { + link_id, + profile: Profile::LatencyLow, + }] + ); + assert_eq!(driven.effects, vec![TelephonyCommandEffect::Noop]); +} + +#[test] +fn rns_endpoint_try_drive_ready_pumps_reticulum_handshake_events() { + let local_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(16); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + + let registered = transport_rx.try_recv().unwrap(); + let TransportMessage::RegisterDestination { + delivery_tx: Some(destination_tx), + .. + } = registered + else { + panic!("expected RegisterDestination, got {registered:?}"); + }; + + let (mut initiator, request_data) = Link::new_initiator(endpoint.destination_hash, 1); + let link_id = initiator.link_id; + destination_tx + .try_send(DestinationEvent::LinkRequest { + raw: build_link_request_packet(endpoint.destination_hash, &request_data), + interface_id: 7, + }) + .unwrap(); + + let mut core = TelephonyRuntimeCore::new(); + assert!(endpoint.try_drive_ready(&mut core).unwrap().is_empty()); + + let (proof_header, proof_data) = take_outbound(&mut transport_rx); + assert_eq!(proof_header.destination_hash, link_id); + assert_eq!( + proof_header.flags.packet_type, + rns_wire::flags::PacketType::Proof + ); + + let register_link = transport_rx.try_recv().unwrap(); + let TransportMessage::RegisterLink { + link_id: registered_link_id, + destination_hash: registered_destination_hash, + interface_id, + initiator: registered_initiator, + .. + } = register_link + else { + panic!("expected RegisterLink, got {register_link:?}"); + }; + assert_eq!(registered_link_id, link_id); + assert_eq!(registered_destination_hash, endpoint.destination_hash); + assert_eq!(interface_id, 7); + assert!(!registered_initiator); + + let local_public_key = local_identity.get_public_key(); + let mut local_ed25519_public_key = [0u8; 32]; + local_ed25519_public_key.copy_from_slice(&local_public_key[32..64]); + let verify_key = Ed25519PublicKey::from_bytes(&local_ed25519_public_key).unwrap(); + let rtt_data = initiator + .validate_proof(&proof_data, &verify_key, &local_ed25519_public_key) + .unwrap(); + + destination_tx + .try_send(DestinationEvent::InboundPacket { + raw: Bytes::from(link_data_packet( + link_id, + rns_wire::context::PacketContext::Lrrtt, + &rtt_data, + )), + interface_id: 7, + }) + .unwrap(); + + let driven = endpoint.try_drive_ready(&mut core).unwrap(); + assert_eq!(driven.len(), 1); + assert_eq!( + driven[0].step.commands, + vec![TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(SignallingStatus::Available), + }] + ); + assert_eq!( + driven[0].effects, + vec![TelephonyCommandEffect::QueuedLinkPacket { + link_id, + kind: QueuedLinkPacketKind::LxstData, + }] + ); + + let (available_header, available_data) = take_outbound(&mut transport_rx); + assert_eq!(available_header.destination_hash, link_id); + let available_plaintext = initiator.decrypt(&available_data).unwrap(); + assert_eq!( + LxstPacket::decode(&available_plaintext).unwrap().signals, + vec![Signal::from(SignallingStatus::Available)] + ); +} + +#[tokio::test] +async fn discover_remote_telephony_peer_uses_recent_announce_pubkey() { + let local_identity = Identity::new(); + let remote_identity = Identity::new(); + let remote_hash = remote_identity.hash; + let remote_public_key = remote_identity.get_public_key(); + let destination_hash = telephony_destination_hash(&remote_hash); + + let (transport_tx, mut transport_rx) = mpsc::channel(16); + let endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.recv().await.unwrap(); + + let discovery = endpoint.discover_remote_telephony_peer(remote_hash, Duration::from_secs(1)); + let transport = async { + let register = transport_rx.recv().await.unwrap(); + let TransportMessage::RegisterAnnounceHandler { + aspect_filter: Some(aspect_filter), + .. + } = register + else { + panic!("expected RegisterAnnounceHandler, got {register:?}"); + }; + assert_eq!(aspect_filter, TELEPHONY_DESTINATION_NAME); + + let rpc = transport_rx.recv().await.unwrap(); + let TransportMessage::Rpc { query, response_tx } = rpc else { + panic!("expected Rpc, got {rpc:?}"); + }; + assert!(matches!(query, TransportQuery::GetRecentAnnounces)); + response_tx + .send(TransportQueryResponse::Announces(vec![announce_entry( + destination_hash, + 3, + Some(remote_public_key), + )])) + .unwrap(); + + let request_path = transport_rx.recv().await.unwrap(); + let TransportMessage::RequestPath { + destination_hash: requested_hash, + } = request_path + else { + panic!("expected RequestPath, got {request_path:?}"); + }; + assert_eq!(requested_hash, destination_hash); + + let deregister = transport_rx.recv().await.unwrap(); + let TransportMessage::DeregisterAnnounceHandler { + aspect_filter: Some(aspect_filter), + } = deregister + else { + panic!("expected DeregisterAnnounceHandler, got {deregister:?}"); + }; + assert_eq!(aspect_filter, TELEPHONY_DESTINATION_NAME); + }; + + let (peer, ()) = tokio::join!(discovery, transport); + assert_eq!( + peer.unwrap(), + RemoteTelephonyPeer { + identity_hash: remote_hash, + destination_hash, + public_key: remote_public_key, + hops: 3, + } + ); +} + +#[tokio::test] +async fn discover_remote_telephony_peer_skips_keyless_recent_announce() { + let local_identity = Identity::new(); + let remote_identity = Identity::new(); + let remote_hash = remote_identity.hash; + let remote_public_key = remote_identity.get_public_key(); + let destination_hash = telephony_destination_hash(&remote_hash); + + let (transport_tx, mut transport_rx) = mpsc::channel(16); + let endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.recv().await.unwrap(); + + let discovery = endpoint.discover_remote_telephony_peer(remote_hash, Duration::from_secs(1)); + let transport = async { + let register = transport_rx.recv().await.unwrap(); + let TransportMessage::RegisterAnnounceHandler { + aspect_filter: Some(aspect_filter), + callback_tx, + .. + } = register + else { + panic!("expected RegisterAnnounceHandler, got {register:?}"); + }; + assert_eq!(aspect_filter, TELEPHONY_DESTINATION_NAME); + + let rpc = transport_rx.recv().await.unwrap(); + let TransportMessage::Rpc { query, response_tx } = rpc else { + panic!("expected Rpc, got {rpc:?}"); + }; + assert!(matches!(query, TransportQuery::GetRecentAnnounces)); + response_tx + .send(TransportQueryResponse::Announces(vec![announce_entry( + destination_hash, + 3, + None, + )])) + .unwrap(); + + let rpc = transport_rx.recv().await.unwrap(); + let TransportMessage::Rpc { query, response_tx } = rpc else { + panic!("expected DropPath Rpc, got {rpc:?}"); + }; + assert!(matches!( + query, + TransportQuery::DropPath { + dest + } if dest == destination_hash + )); + response_tx.send(TransportQueryResponse::Ok).unwrap(); + + let request_path = transport_rx.recv().await.unwrap(); + let TransportMessage::RequestPath { + destination_hash: requested_hash, + } = request_path + else { + panic!("expected RequestPath, got {request_path:?}"); + }; + assert_eq!(requested_hash, destination_hash); + + callback_tx + .send(announce_event(destination_hash, 2, None)) + .await + .unwrap(); + callback_tx + .send(announce_event(destination_hash, 1, Some(remote_public_key))) + .await + .unwrap(); + + let deregister = transport_rx.recv().await.unwrap(); + let TransportMessage::DeregisterAnnounceHandler { + aspect_filter: Some(aspect_filter), + } = deregister + else { + panic!("expected DeregisterAnnounceHandler, got {deregister:?}"); + }; + assert_eq!(aspect_filter, TELEPHONY_DESTINATION_NAME); + }; + + let (peer, ()) = tokio::join!(discovery, transport); + assert_eq!( + peer.unwrap(), + RemoteTelephonyPeer { + identity_hash: remote_hash, + destination_hash, + public_key: remote_public_key, + hops: 1, + } + ); +} + +#[tokio::test] +async fn await_path_to_identity_uses_telephony_destination_hash() { + let local_identity = Identity::new(); + let remote_identity = Identity::new(); + let remote_hash = remote_identity.hash; + let destination_hash = telephony_destination_hash(&remote_hash); + + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.recv().await.unwrap(); + + let await_path = endpoint.await_path_to_identity(remote_hash, Duration::from_secs(1)); + let transport = async { + let message = transport_rx.recv().await.unwrap(); + let TransportMessage::AwaitPath { dest, reply } = message else { + panic!("expected AwaitPath, got {message:?}"); + }; + assert_eq!(dest, destination_hash); + reply.send(true).unwrap(); + }; + + let (result, ()) = tokio::join!(await_path, transport); + result.unwrap(); +} + +#[tokio::test] +async fn endpoint_announce_sends_lxst_telephony_announce_with_public_key() { + let identity = Identity::new(); + let destination_hash = telephony_destination_hash(&identity.hash); + let public_key = identity.get_public_key(); + + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let endpoint = TelephonyRnsEndpoint::register(transport_tx, &identity).unwrap(); + let _listener_registration = transport_rx.recv().await.unwrap(); + + endpoint.announce().unwrap(); + + let (header, payload) = take_outbound(&mut transport_rx); + assert_eq!(header.destination_hash, destination_hash); + assert_eq!( + header.flags.packet_type, + rns_wire::flags::PacketType::Announce + ); + assert_eq!(header.context, rns_wire::context::PacketContext::None); + + let announce = AnnounceData::unpack(&payload, header.flags.context_flag).unwrap(); + assert_eq!(announce.public_key, public_key); + assert_eq!(announce.name_hash, name_hash(TELEPHONY_DESTINATION_NAME)); + let announced_identity = announce.validate(&destination_hash).unwrap(); + assert_eq!(announced_identity.hash, identity.hash); +} + +#[tokio::test] +async fn begin_outgoing_link_discovers_announce_and_sends_link_request() { + let local_identity = Identity::new(); + let remote_identity = Identity::new(); + let remote_hash = remote_identity.hash; + let remote_public_key = remote_identity.get_public_key(); + let destination_hash = telephony_destination_hash(&remote_hash); + + let (transport_tx, mut transport_rx) = mpsc::channel(16); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.recv().await.unwrap(); + let mut core = TelephonyRuntimeCore::new(); + + let call = endpoint.begin_outgoing_link( + &mut core, + remote_hash, + Some(Profile::LatencyLow), + Duration::from_secs(1), + ); + let transport = async { + let register = transport_rx.recv().await.unwrap(); + let TransportMessage::RegisterAnnounceHandler { + aspect_filter: Some(aspect_filter), + receive_path_responses, + callback_tx, + } = register + else { + panic!("expected RegisterAnnounceHandler, got {register:?}"); + }; + assert_eq!(aspect_filter, TELEPHONY_DESTINATION_NAME); + assert!(receive_path_responses); + + let rpc = transport_rx.recv().await.unwrap(); + let TransportMessage::Rpc { query, response_tx } = rpc else { + panic!("expected Rpc, got {rpc:?}"); + }; + assert!(matches!(query, TransportQuery::GetRecentAnnounces)); + response_tx + .send(TransportQueryResponse::Announces(Vec::new())) + .unwrap(); + + let rpc = transport_rx.recv().await.unwrap(); + let TransportMessage::Rpc { query, response_tx } = rpc else { + panic!("expected DropPath Rpc, got {rpc:?}"); + }; + assert!(matches!( + query, + TransportQuery::DropPath { + dest + } if dest == destination_hash + )); + response_tx.send(TransportQueryResponse::Ok).unwrap(); + + let request_path = transport_rx.recv().await.unwrap(); + let TransportMessage::RequestPath { + destination_hash: requested_hash, + } = request_path + else { + panic!("expected discovery RequestPath, got {request_path:?}"); + }; + assert_eq!(requested_hash, destination_hash); + + callback_tx + .send(announce_event(destination_hash, 2, Some(remote_public_key))) + .await + .unwrap(); + + let deregister = transport_rx.recv().await.unwrap(); + let TransportMessage::DeregisterAnnounceHandler { + aspect_filter: Some(aspect_filter), + } = deregister + else { + panic!("expected DeregisterAnnounceHandler, got {deregister:?}"); + }; + assert_eq!(aspect_filter, TELEPHONY_DESTINATION_NAME); + + let await_path = transport_rx.recv().await.unwrap(); + let TransportMessage::AwaitPath { dest, reply } = await_path else { + panic!("expected AwaitPath, got {await_path:?}"); + }; + assert_eq!(dest, destination_hash); + reply.send(true).unwrap(); + + let request_path = transport_rx.recv().await.unwrap(); + let TransportMessage::RequestPath { + destination_hash: requested_hash, + } = request_path + else { + panic!("expected link RequestPath, got {request_path:?}"); + }; + assert_eq!(requested_hash, destination_hash); + + let register_link = transport_rx.recv().await.unwrap(); + let TransportMessage::RegisterDestination { + hash: registered_link_id, + delivery_tx: Some(_), + .. + } = register_link + else { + panic!("expected link RegisterDestination, got {register_link:?}"); + }; + + let (request_header, request_data) = take_outbound(&mut transport_rx); + assert_eq!(request_header.destination_hash, destination_hash); + assert_eq!( + request_header.flags.packet_type, + rns_wire::flags::PacketType::LinkRequest + ); + assert!(!request_data.is_empty()); + + registered_link_id + }; + + let (link_id, registered_link_id) = tokio::join!(call, transport); + let link_id = link_id.unwrap(); + assert_eq!(link_id, registered_link_id); + assert_eq!(core.active_call().unwrap().link_id, link_id); + assert_eq!(core.active_call().unwrap().remote_identity, remote_hash); +} + +#[tokio::test] +async fn telephony_service_registered_helper_wires_channels_and_lifecycle() { + let local_identity = Identity::new(); + let destination_hash = telephony_destination_hash(&local_identity.hash); + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let TelephonyServiceParts { + service, + control_tx, + mut event_rx, + } = TelephonyService::registered_with_config( + transport_tx, + &local_identity, + TelephonyServiceConfig { + poll_interval: Duration::from_millis(100), + announce_on_start: false, + ..TelephonyServiceConfig::default() + }, + TelephonyServiceChannelConfig { + control_capacity: 0, + event_capacity: 0, + }, + ) + .unwrap(); + + let register = transport_rx.recv().await.unwrap(); + let TransportMessage::RegisterDestination { + hash: registered_hash, + app_name, + delivery_tx: Some(_), + } = register + else { + panic!("expected RegisterDestination, got {register:?}"); + }; + assert_eq!(registered_hash, destination_hash); + assert_eq!(app_name, TELEPHONY_DESTINATION_NAME); + + let service_task = tokio::spawn(service.run()); + control_tx.send(TelephonyControl::Shutdown).await.unwrap(); + + let stopped = timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(stopped, TelephonyServiceEvent::Stopped); + service_task.await.unwrap(); + + assert_deregistered_destination(&mut transport_rx, destination_hash); +} + +#[tokio::test] +async fn telephony_service_announces_on_start() { + let local_identity = Identity::new(); + let destination_hash = telephony_destination_hash(&local_identity.hash); + let (transport_tx, mut transport_rx) = mpsc::channel(8); + let TelephonyServiceParts { + service, + control_tx, + mut event_rx, + } = TelephonyService::registered_with_config( + transport_tx, + &local_identity, + TelephonyServiceConfig { + poll_interval: Duration::from_millis(10), + announce_interval: None, + ..TelephonyServiceConfig::default() + }, + TelephonyServiceChannelConfig::default(), + ) + .unwrap(); + + let _listener_registration = transport_rx.recv().await.unwrap(); + let service_task = tokio::spawn(service.run()); + + let announce = timeout(Duration::from_secs(1), transport_rx.recv()) + .await + .unwrap() + .unwrap(); + let TransportMessage::Outbound(announce) = announce else { + panic!("expected telephony announce Outbound, got {announce:?}"); + }; + let (header, data_offset) = rns_wire::header::PacketHeader::unpack(&announce.raw).unwrap(); + assert_eq!(header.destination_hash, destination_hash); + assert_eq!( + header.flags.packet_type, + rns_wire::flags::PacketType::Announce + ); + let announce_data = + AnnounceData::unpack(&announce.raw[data_offset..], header.flags.context_flag).unwrap(); + announce_data.validate(&destination_hash).unwrap(); + + control_tx.send(TelephonyControl::Shutdown).await.unwrap(); + let stopped = timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(stopped, TelephonyServiceEvent::Stopped); + service_task.await.unwrap(); +} + +#[tokio::test] +async fn telephony_service_retries_startup_announces_before_regular_interval() { + let local_identity = Identity::new(); + let destination_hash = telephony_destination_hash(&local_identity.hash); + let (transport_tx, mut transport_rx) = mpsc::channel(8); + let TelephonyServiceParts { + service, + control_tx, + mut event_rx, + } = TelephonyService::registered_with_config( + transport_tx, + &local_identity, + TelephonyServiceConfig { + poll_interval: Duration::from_millis(5), + announce_interval: None, + startup_announce_retry_interval: Some(Duration::from_millis(20)), + startup_announce_retries: 2, + ..TelephonyServiceConfig::default() + }, + TelephonyServiceChannelConfig::default(), + ) + .unwrap(); + + let _listener_registration = transport_rx.recv().await.unwrap(); + let service_task = tokio::spawn(service.run()); + + for _ in 0..3 { + let announce = timeout(Duration::from_secs(1), transport_rx.recv()) + .await + .unwrap() + .unwrap(); + let TransportMessage::Outbound(announce) = announce else { + panic!("expected telephony announce Outbound, got {announce:?}"); + }; + let (header, data_offset) = rns_wire::header::PacketHeader::unpack(&announce.raw).unwrap(); + assert_eq!(header.destination_hash, destination_hash); + assert_eq!( + header.flags.packet_type, + rns_wire::flags::PacketType::Announce + ); + AnnounceData::unpack(&announce.raw[data_offset..], header.flags.context_flag) + .unwrap() + .validate(&destination_hash) + .unwrap(); + } + + assert!( + timeout(Duration::from_millis(60), transport_rx.recv()) + .await + .is_err(), + "service should stop startup announces after configured retry count" + ); + + control_tx.send(TelephonyControl::Shutdown).await.unwrap(); + let stopped = timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(stopped, TelephonyServiceEvent::Stopped); + service_task.await.unwrap(); +} + +#[tokio::test] +async fn telephony_service_announce_control_queues_telephony_announce() { + let local_identity = Identity::new(); + let destination_hash = telephony_destination_hash(&local_identity.hash); + let (transport_tx, mut transport_rx) = mpsc::channel(8); + let TelephonyServiceParts { + service, + control_tx, + mut event_rx, + } = TelephonyService::registered_with_config( + transport_tx, + &local_identity, + TelephonyServiceConfig { + announce_on_start: false, + announce_interval: None, + ..TelephonyServiceConfig::default() + }, + TelephonyServiceChannelConfig::default(), + ) + .unwrap(); + + let _listener_registration = transport_rx.recv().await.unwrap(); + let service_task = tokio::spawn(service.run()); + + control_tx.send(TelephonyControl::Announce).await.unwrap(); + let announce = timeout(Duration::from_secs(1), transport_rx.recv()) + .await + .unwrap() + .unwrap(); + let TransportMessage::Outbound(announce) = announce else { + panic!("expected telephony announce Outbound, got {announce:?}"); + }; + let (header, data_offset) = rns_wire::header::PacketHeader::unpack(&announce.raw).unwrap(); + assert_eq!(header.destination_hash, destination_hash); + assert_eq!( + header.flags.packet_type, + rns_wire::flags::PacketType::Announce + ); + AnnounceData::unpack(&announce.raw[data_offset..], header.flags.context_flag) + .unwrap() + .validate(&destination_hash) + .unwrap(); + + control_tx.send(TelephonyControl::Shutdown).await.unwrap(); + let stopped = timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(stopped, TelephonyServiceEvent::Stopped); + service_task.await.unwrap(); +} + +#[tokio::test] +async fn telephony_service_call_control_discovers_peer_and_emits_started() { + let local_identity = Identity::new(); + let remote_identity = Identity::new(); + let remote_hash = remote_identity.hash; + let local_destination_hash = telephony_destination_hash(&local_identity.hash); + let remote_public_key = remote_identity.get_public_key(); + let destination_hash = telephony_destination_hash(&remote_hash); + + let (transport_tx, mut transport_rx) = mpsc::channel(16); + let endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.recv().await.unwrap(); + + let (control_tx, control_rx) = mpsc::channel(8); + let (event_tx, mut event_rx) = mpsc::channel(8); + let service = TelephonyService::with_config( + endpoint, + TelephonyRuntimeCore::new(), + control_rx, + event_tx, + TelephonyServiceConfig { + poll_interval: Duration::from_millis(100), + announce_on_start: false, + ..TelephonyServiceConfig::default() + }, + ); + let service_task = tokio::spawn(service.run()); + + control_tx + .send(TelephonyControl::Call { + remote_identity: remote_hash, + profile: Some(Profile::LatencyLow), + discovery_timeout: Duration::from_secs(1), + }) + .await + .unwrap(); + + let register = transport_rx.recv().await.unwrap(); + let TransportMessage::RegisterAnnounceHandler { + aspect_filter: Some(aspect_filter), + receive_path_responses, + callback_tx, + } = register + else { + panic!("expected RegisterAnnounceHandler, got {register:?}"); + }; + assert_eq!(aspect_filter, TELEPHONY_DESTINATION_NAME); + assert!(receive_path_responses); + + let rpc = transport_rx.recv().await.unwrap(); + let TransportMessage::Rpc { query, response_tx } = rpc else { + panic!("expected Rpc, got {rpc:?}"); + }; + assert!(matches!(query, TransportQuery::GetRecentAnnounces)); + response_tx + .send(TransportQueryResponse::Announces(Vec::new())) + .unwrap(); + + let rpc = transport_rx.recv().await.unwrap(); + let TransportMessage::Rpc { query, response_tx } = rpc else { + panic!("expected DropPath Rpc, got {rpc:?}"); + }; + assert!(matches!( + query, + TransportQuery::DropPath { + dest + } if dest == destination_hash + )); + response_tx.send(TransportQueryResponse::Ok).unwrap(); + + let request_path = transport_rx.recv().await.unwrap(); + let TransportMessage::RequestPath { + destination_hash: requested_hash, + } = request_path + else { + panic!("expected discovery RequestPath, got {request_path:?}"); + }; + assert_eq!(requested_hash, destination_hash); + + callback_tx + .send(announce_event(destination_hash, 1, Some(remote_public_key))) + .await + .unwrap(); + + let deregister = transport_rx.recv().await.unwrap(); + let TransportMessage::DeregisterAnnounceHandler { + aspect_filter: Some(aspect_filter), + } = deregister + else { + panic!("expected DeregisterAnnounceHandler, got {deregister:?}"); + }; + assert_eq!(aspect_filter, TELEPHONY_DESTINATION_NAME); + + let await_path = transport_rx.recv().await.unwrap(); + let TransportMessage::AwaitPath { dest, reply } = await_path else { + panic!("expected AwaitPath, got {await_path:?}"); + }; + assert_eq!(dest, destination_hash); + reply.send(true).unwrap(); + + let request_path = transport_rx.recv().await.unwrap(); + let TransportMessage::RequestPath { + destination_hash: requested_hash, + } = request_path + else { + panic!("expected link RequestPath, got {request_path:?}"); + }; + assert_eq!(requested_hash, destination_hash); + + let register_link = transport_rx.recv().await.unwrap(); + let TransportMessage::RegisterDestination { + hash: registered_link_id, + delivery_tx: Some(_), + .. + } = register_link + else { + panic!("expected link RegisterDestination, got {register_link:?}"); + }; + + let (request_header, _) = take_outbound(&mut transport_rx); + assert_eq!( + request_header.flags.packet_type, + rns_wire::flags::PacketType::LinkRequest + ); + + let event = timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!( + event, + TelephonyServiceEvent::OutgoingCallStarted { + link_id: registered_link_id, + remote_identity: remote_hash, + } + ); + + let event = timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!( + event, + TelephonyServiceEvent::Snapshot(TelephonyRuntimeSnapshot { + external_busy: false, + pending_link_count: 0, + active_call: Some(ActiveCallSnapshot { + link_id: registered_link_id, + remote_identity: remote_hash, + role: CallRole::Outgoing, + status: SignallingStatus::Calling, + profile: Some(Profile::LatencyLow), + answered: false, + }), + }) + ); + + control_tx.send(TelephonyControl::Shutdown).await.unwrap(); + let event = timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + let TelephonyServiceEvent::Drive(step) = event else { + panic!("expected Drive event, got {event:?}"); + }; + assert_eq!( + step.step.commands, + vec![ + TelephonyCommand::TeardownLink { + link_id: registered_link_id, + }, + TelephonyCommand::StopAudioPipelines { + link_id: registered_link_id, + }, + TelephonyCommand::CallTerminated { + link_id: registered_link_id, + reason: None, + }, + ] + ); + assert_eq!( + step.effects, + vec![ + TelephonyCommandEffect::Noop, + TelephonyCommandEffect::Noop, + TelephonyCommandEffect::Noop, + ] + ); + + let event = timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!( + event, + TelephonyServiceEvent::CallTerminated { + link_id: registered_link_id, + reason: None, + } + ); + + let event = timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!( + event, + TelephonyServiceEvent::Snapshot(TelephonyRuntimeSnapshot { + external_busy: false, + pending_link_count: 0, + active_call: None, + }) + ); + + let stopped = timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(stopped, TelephonyServiceEvent::Stopped); + service_task.await.unwrap(); + + assert_deregistered_link(&mut transport_rx, registered_link_id); + assert_deregistered_destination(&mut transport_rx, local_destination_hash); +} + +#[tokio::test] +async fn telephony_service_send_opus_frames_queues_decodeable_quality_media() { + let (sender, receiver) = active_link_pair(); + let link_id = sender.link_id; + let profile = Profile::QualityMedium; + let frame = synthetic_frame_for_profile(profile); + let local_identity = Identity::new(); + let remote_identity = identity(0xA7); + + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (_event_tx, event_rx) = mpsc::channel(1); + endpoint.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: sender, + event_rx, + }, + ); + + let mut core = TelephonyRuntimeCore::new(); + core.start_outgoing_call(link_id, remote_identity, Some(profile)) + .unwrap(); + for status in [ + SignallingStatus::Available, + SignallingStatus::Ringing, + SignallingStatus::Connecting, + SignallingStatus::Established, + ] { + core.accept_lxst_plaintext(link_id, &packet([Signal::from(status)])) + .unwrap(); + } + + let (_control_tx, control_rx) = mpsc::channel(1); + let (event_tx, mut service_events) = mpsc::channel(4); + let mut service = TelephonyService::new(endpoint, core, control_rx, event_tx); + + service + .send_opus_frames(profile, vec![frame.clone()]) + .await + .unwrap(); + let first_encoder_generation = service.media.opus_encoder_generation; + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::MediaSent { + link_id, + frames: 1, + packets: 1, + } + ); + + let (header, encrypted) = take_outbound(&mut transport_rx); + assert_eq!(header.destination_hash, link_id); + assert_eq!(header.context, rns_wire::context::PacketContext::None); + + let plaintext = receiver.decrypt(&encrypted).unwrap(); + let packet = LxstPacket::decode(&plaintext).unwrap(); + assert_eq!(packet.frames.len(), 1); + assert_eq!(packet.frames[0].codec, CodecKind::Opus); + assert_eq!(packet.frames[0].payload[0] & 0x03, 0x03); + assert_eq!(packet.frames[0].payload[1] & 0x3F, 3); + assert!(packet.frames[0].payload.len() <= profile.opus_payload_ceiling_bytes().unwrap()); + + let mut decoder = OpusDecoderState::new(profile).unwrap(); + let decoded = decoder.decode_frame(&packet.frames[0]).unwrap(); + assert_eq!(decoded.channels, profile.channels()); + assert_eq!(decoded.sample_frames(), profile.sample_frames_per_packet()); + + service + .send_opus_frames(profile, vec![synthetic_frame_for_profile(profile)]) + .await + .unwrap(); + assert_eq!( + service.media.opus_encoder_generation, + first_encoder_generation + ); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::MediaSent { + link_id, + frames: 1, + packets: 1, + } + ); + let (_header, _encrypted) = take_outbound(&mut transport_rx); + + assert!(matches!( + service + .send_opus_frames( + Profile::LatencyLow, + vec![synthetic_frame_for_profile(Profile::LatencyLow)] + ) + .await, + Err(Error::MediaProfileMismatch { + active: Profile::QualityMedium, + requested: Profile::LatencyLow, + }) + )); + + let _commands = service.core.hangup_active(false).unwrap(); + service.refresh_active_timeout(); + assert!(service.media.opus_encoder.is_none()); +} + +#[tokio::test] +async fn telephony_service_pumps_owned_opus_stream_in_bounded_batches() { + let (sender, receiver) = active_link_pair(); + let link_id = sender.link_id; + let profile = Profile::LatencyLow; + let local_identity = Identity::new(); + let remote_identity = identity(0xA9); + + let (transport_tx, mut transport_rx) = mpsc::channel(16); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (_event_tx, event_rx) = mpsc::channel(1); + endpoint.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: sender, + event_rx, + }, + ); + + let mut core = TelephonyRuntimeCore::new(); + core.start_outgoing_call(link_id, remote_identity, Some(profile)) + .unwrap(); + for status in [ + SignallingStatus::Available, + SignallingStatus::Ringing, + SignallingStatus::Connecting, + SignallingStatus::Established, + ] { + core.accept_lxst_plaintext(link_id, &packet([Signal::from(status)])) + .unwrap(); + } + + let (_control_tx, control_rx) = mpsc::channel(1); + let (event_tx, mut service_events) = mpsc::channel(8); + let mut service = TelephonyService::with_config( + endpoint, + core, + control_rx, + event_tx, + TelephonyServiceConfig { + media_frames_per_tick: 2, + ..TelephonyServiceConfig::default() + }, + ); + let (frame_tx, frame_rx) = mpsc::channel(8); + for _ in 0..5 { + frame_tx + .try_send(synthetic_frame_for_profile(profile)) + .unwrap(); + } + drop(frame_tx); + + service.start_opus_stream(profile, frame_rx).await.unwrap(); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusTransmitStreamStarted { link_id, profile } + ); + + let mut decoder = OpusDecoderState::new(profile).unwrap(); + for expected_frames in [2, 2, 1] { + assert!(service.pump_opus_stream().await); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::MediaSent { + link_id, + frames: expected_frames, + packets: expected_frames, + } + ); + + for _ in 0..expected_frames { + let (header, encrypted) = take_outbound(&mut transport_rx); + assert_eq!(header.destination_hash, link_id); + assert_eq!(header.context, rns_wire::context::PacketContext::None); + let plaintext = receiver.decrypt(&encrypted).unwrap(); + let packet = LxstPacket::decode(&plaintext).unwrap(); + assert_eq!(packet.frames.len(), 1); + assert_eq!(packet.frames[0].codec, CodecKind::Opus); + let decoded = decoder.decode_frame(&packet.frames[0]).unwrap(); + assert_eq!(decoded.channels, profile.channels()); + assert_eq!(decoded.sample_frames(), profile.sample_frames_per_packet()); + } + } + + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusTransmitStreamStopped { + link_id, + profile, + reason: OpusTransmitStreamStopReason::SourceClosed, + } + ); + assert!(service.media.opus_transmit_stream.is_none()); + assert_eq!(service.media.opus_encoder_generation, 1); +} + +#[tokio::test] +async fn telephony_service_decodes_inbound_opus_frames_with_call_profile() { + let (sender, receiver) = active_link_pair(); + let link_id = sender.link_id; + let profile = Profile::QualityMedium; + let local_identity = Identity::new(); + let remote_identity = identity(0xA8); + + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (link_event_tx, event_rx) = mpsc::channel(4); + endpoint.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: sender, + event_rx, + }, + ); + + let mut core = TelephonyRuntimeCore::new(); + core.start_outgoing_call(link_id, remote_identity, Some(profile)) + .unwrap(); + for status in [ + SignallingStatus::Available, + SignallingStatus::Ringing, + SignallingStatus::Connecting, + SignallingStatus::Established, + ] { + core.accept_lxst_plaintext(link_id, &packet([Signal::from(status)])) + .unwrap(); + } + + let (_control_tx, control_rx) = mpsc::channel(1); + let (event_tx, mut service_events) = mpsc::channel(8); + let mut service = TelephonyService::new(endpoint, core, control_rx, event_tx); + let mut encoder = OpusEncoderState::new(profile).unwrap(); + + for expected_generation in [1, 1] { + let opus_frame = encoder + .encode_frame(&synthetic_frame_for_profile(profile)) + .unwrap(); + let plaintext = LxstPacket::frame(opus_frame).encode().unwrap(); + let encrypted = receiver.encrypt(&plaintext).unwrap(); + link_event_tx + .try_send(DestinationEvent::InboundPacket { + raw: Bytes::from(link_data_packet( + link_id, + rns_wire::context::PacketContext::None, + &encrypted, + )), + interface_id: 1, + }) + .unwrap(); + + assert!(service.drive_ready().await); + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Drive(_) + )); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::MediaReceived { link_id, frames: 1 } + ); + let decoded = service_events.recv().await.unwrap(); + let TelephonyServiceEvent::OpusFramesReceived { + link_id: decoded_link, + profile: decoded_profile, + frames, + } = decoded + else { + panic!("expected decoded Opus frames event, got {decoded:?}"); + }; + assert_eq!(decoded_link, link_id); + assert_eq!(decoded_profile, profile); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].channels, profile.channels()); + assert_eq!( + frames[0].sample_frames(), + profile.sample_frames_per_packet() + ); + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Snapshot(_) + )); + assert_eq!(service.media.opus_decoder_generation, expected_generation); + } +} + +#[tokio::test] +async fn telephony_service_delivers_decoded_opus_to_receive_stream() { + let profile = Profile::LatencyLow; + let (mut service, receiver, link_id, link_event_tx, mut service_events) = + established_outgoing_service(profile, 0xAA, 16); + let (sink_tx, mut sink_rx) = mpsc::channel(4); + + service.start_opus_receive_stream(sink_tx).await.unwrap(); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamStarted { link_id, profile } + ); + + let mut encoder = OpusEncoderState::new(profile).unwrap(); + queue_inbound_opus_frame( + &link_event_tx, + &receiver, + link_id, + &mut encoder, + synthetic_frame_for_profile(profile), + ); + + assert!(service.drive_ready().await); + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Drive(_) + )); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::MediaReceived { link_id, frames: 1 } + ); + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusFramesReceived { + link_id: event_link, + profile: event_profile, + frames, + } if event_link == link_id + && event_profile == profile + && frames.len() == 1 + )); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamFrames { + link_id, + profile, + frames: 1, + dropped: 0, + } + ); + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Snapshot(_) + )); + + let delivered = sink_rx.try_recv().unwrap(); + assert_eq!(delivered.channels, profile.channels()); + assert_eq!( + delivered.sample_frames(), + profile.sample_frames_per_packet() + ); + + assert!( + service + .stop_opus_receive_stream(OpusReceiveStreamStopReason::Requested) + .await + ); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamStopped { + link_id, + profile, + reason: OpusReceiveStreamStopReason::Requested, + } + ); +} + +#[tokio::test] +async fn telephony_service_reports_receive_stream_backpressure() { + let profile = Profile::LatencyLow; + let (mut service, receiver, link_id, link_event_tx, mut service_events) = + established_outgoing_service(profile, 0xAB, 16); + let (sink_tx, mut sink_rx) = mpsc::channel(1); + let queued = synthetic_frame_for_profile(profile); + sink_tx.try_send(queued.clone()).unwrap(); + + service.start_opus_receive_stream(sink_tx).await.unwrap(); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamStarted { link_id, profile } + ); + + let mut encoder = OpusEncoderState::new(profile).unwrap(); + queue_inbound_opus_frame( + &link_event_tx, + &receiver, + link_id, + &mut encoder, + synthetic_frame_for_profile(profile), + ); + + assert!(service.drive_ready().await); + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Drive(_) + )); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::MediaReceived { link_id, frames: 1 } + ); + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusFramesReceived { .. } + )); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamFrames { + link_id, + profile, + frames: 0, + dropped: 1, + } + ); + assert!(service.media.opus_receive_stream.is_some()); + assert_eq!(sink_rx.try_recv().unwrap(), queued); +} + +#[tokio::test] +async fn telephony_service_stops_receive_stream_when_sink_closes() { + let profile = Profile::LatencyLow; + let (mut service, receiver, link_id, link_event_tx, mut service_events) = + established_outgoing_service(profile, 0xAC, 16); + let (sink_tx, sink_rx) = mpsc::channel(1); + drop(sink_rx); + + service.start_opus_receive_stream(sink_tx).await.unwrap(); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamStarted { link_id, profile } + ); + + let mut encoder = OpusEncoderState::new(profile).unwrap(); + queue_inbound_opus_frame( + &link_event_tx, + &receiver, + link_id, + &mut encoder, + synthetic_frame_for_profile(profile), + ); + + assert!(service.drive_ready().await); + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Drive(_) + )); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::MediaReceived { link_id, frames: 1 } + ); + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusFramesReceived { .. } + )); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamStopped { + link_id, + profile, + reason: OpusReceiveStreamStopReason::SinkClosed, + } + ); + assert!(service.media.opus_receive_stream.is_none()); +} + +#[tokio::test] +async fn telephony_service_stops_media_streams_when_call_ends() { + let profile = Profile::LatencyLow; + let (mut service, _receiver, link_id, _link_event_tx, mut service_events, _transport_rx) = + established_outgoing_service_with_transport(profile, 0xAD, 16); + let (_source_tx, source_rx) = mpsc::channel(1); + let (sink_tx, _sink_rx) = mpsc::channel(1); + + service.start_opus_stream(profile, source_rx).await.unwrap(); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusTransmitStreamStarted { link_id, profile } + ); + service.start_opus_receive_stream(sink_tx).await.unwrap(); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamStarted { link_id, profile } + ); + + let commands = service.core.hangup_active(false); + service.control_commands(commands).await.unwrap(); + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Drive(_) + )); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::CallTerminated { + link_id, + reason: None, + } + ); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusTransmitStreamStopped { + link_id, + profile, + reason: OpusTransmitStreamStopReason::CallEnded, + } + ); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamStopped { + link_id, + profile, + reason: OpusReceiveStreamStopReason::CallEnded, + } + ); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Snapshot(TelephonyRuntimeSnapshot { + external_busy: false, + pending_link_count: 0, + active_call: None, + }) + ); + assert!(service.media.opus_transmit_stream.is_none()); + assert!(service.media.opus_receive_stream.is_none()); +} + +#[tokio::test] +async fn telephony_service_stops_media_streams_on_remote_link_close() { + let profile = Profile::LatencyLow; + let (mut service, _receiver, link_id, link_event_tx, mut service_events, mut transport_rx) = + established_outgoing_service_with_transport(profile, 0xAF, 16); + let (_source_tx, source_rx) = mpsc::channel(1); + let (sink_tx, _sink_rx) = mpsc::channel(1); + + service.start_opus_stream(profile, source_rx).await.unwrap(); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusTransmitStreamStarted { link_id, profile } + ); + service.start_opus_receive_stream(sink_tx).await.unwrap(); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamStarted { link_id, profile } + ); + + link_event_tx + .try_send(DestinationEvent::LinkClosed { link_id }) + .unwrap(); + assert!(service.drive_ready().await); + + assert_deregistered_link(&mut transport_rx, link_id); + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Drive(_) + )); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::CallTerminated { + link_id, + reason: None, + } + ); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusTransmitStreamStopped { + link_id, + profile, + reason: OpusTransmitStreamStopReason::CallEnded, + } + ); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamStopped { + link_id, + profile, + reason: OpusReceiveStreamStopReason::CallEnded, + } + ); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Snapshot(TelephonyRuntimeSnapshot { + external_busy: false, + pending_link_count: 0, + active_call: None, + }) + ); + assert!(service.media.opus_transmit_stream.is_none()); + assert!(service.media.opus_receive_stream.is_none()); +} + +#[tokio::test] +async fn telephony_service_stops_media_streams_on_established_profile_switch() { + let old_profile = Profile::LatencyLow; + let new_profile = Profile::QualityMedium; + let (mut service, receiver, link_id, link_event_tx, mut service_events, _transport_rx) = + established_outgoing_service_with_transport(old_profile, 0xB0, 16); + let (_source_tx, source_rx) = mpsc::channel(1); + let (sink_tx, _sink_rx) = mpsc::channel(1); + + service + .start_opus_stream(old_profile, source_rx) + .await + .unwrap(); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusTransmitStreamStarted { + link_id, + profile: old_profile, + } + ); + service.start_opus_receive_stream(sink_tx).await.unwrap(); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamStarted { + link_id, + profile: old_profile, + } + ); + + let plaintext = packet([Signal::from(new_profile)]); + let encrypted = receiver.encrypt(&plaintext).unwrap(); + link_event_tx + .try_send(DestinationEvent::InboundPacket { + raw: Bytes::from(link_data_packet( + link_id, + rns_wire::context::PacketContext::None, + &encrypted, + )), + interface_id: 1, + }) + .unwrap(); + assert!(service.drive_ready().await); + + assert!(matches!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Drive(_) + )); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusTransmitStreamStopped { + link_id, + profile: old_profile, + reason: OpusTransmitStreamStopReason::ProfileChanged, + } + ); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::OpusReceiveStreamStopped { + link_id, + profile: old_profile, + reason: OpusReceiveStreamStopReason::ProfileChanged, + } + ); + assert_eq!( + service_events.recv().await.unwrap(), + TelephonyServiceEvent::Snapshot(TelephonyRuntimeSnapshot { + external_busy: false, + pending_link_count: 0, + active_call: Some(ActiveCallSnapshot { + link_id, + role: CallRole::Outgoing, + status: SignallingStatus::Established, + profile: Some(new_profile), + remote_identity: identity(0xB0), + answered: false, + }), + }) + ); + assert!(service.media.opus_transmit_stream.is_none()); + assert!(service.media.opus_receive_stream.is_none()); +} + +#[tokio::test] +async fn telephony_service_soaks_bidirectional_opus_stream_loop() { + let profile = Profile::LatencyLow; + let (mut service, receiver, link_id, link_event_tx, mut service_events, mut transport_rx) = + established_outgoing_service_with_transport(profile, 0xAE, 128); + service.config.media_frames_per_tick = 5; + + let (source_tx, source_rx) = mpsc::channel(16); + for _ in 0..12 { + source_tx + .try_send(synthetic_frame_for_profile(profile)) + .unwrap(); + } + drop(source_tx); + let (sink_tx, mut sink_rx) = mpsc::channel(8); + service.start_opus_stream(profile, source_rx).await.unwrap(); + service.start_opus_receive_stream(sink_tx).await.unwrap(); + assert_eq!(collect_ready_service_events(&mut service_events).len(), 2); + + let mut remote_encoder = OpusEncoderState::new(profile).unwrap(); + let mut outbound_decoder = OpusDecoderState::new(profile).unwrap(); + let mut media_sent_frames = 0; + let mut delivered_receive_frames = 0; + let mut decoded_events = 0; + let mut transmit_stream_stopped = false; + + for expected_outbound_frames in [5, 5, 2] { + queue_inbound_opus_frame( + &link_event_tx, + &receiver, + link_id, + &mut remote_encoder, + synthetic_frame_for_profile(profile), + ); + assert!(service.drive_ready().await); + assert!(service.pump_opus_stream().await); + + for _ in 0..expected_outbound_frames { + let (header, encrypted) = take_outbound(&mut transport_rx); + assert_eq!(header.destination_hash, link_id); + assert_eq!(header.context, rns_wire::context::PacketContext::None); + let plaintext = receiver.decrypt(&encrypted).unwrap(); + let packet = LxstPacket::decode(&plaintext).unwrap(); + assert_eq!(packet.frames.len(), 1); + assert_eq!(packet.frames[0].codec, CodecKind::Opus); + let decoded = outbound_decoder.decode_frame(&packet.frames[0]).unwrap(); + assert_eq!(decoded.channels, profile.channels()); + assert_eq!(decoded.sample_frames(), profile.sample_frames_per_packet()); + } + + for event in collect_ready_service_events(&mut service_events) { + match event { + TelephonyServiceEvent::MediaSent { frames, .. } => { + media_sent_frames += frames; + } + TelephonyServiceEvent::OpusFramesReceived { frames, .. } => { + decoded_events += frames.len(); + } + TelephonyServiceEvent::OpusReceiveStreamFrames { + frames, dropped, .. + } => { + delivered_receive_frames += frames; + assert_eq!(dropped, 0); + } + TelephonyServiceEvent::OpusTransmitStreamStopped { + reason: OpusTransmitStreamStopReason::SourceClosed, + .. + } => { + transmit_stream_stopped = true; + } + _ => {} + } + } + } + + assert_eq!(media_sent_frames, 12); + assert_eq!(decoded_events, 3); + assert_eq!(delivered_receive_frames, 3); + assert!(transmit_stream_stopped); + for _ in 0..3 { + let frame = sink_rx.try_recv().unwrap(); + assert_eq!(frame.channels, profile.channels()); + assert_eq!(frame.sample_frames(), profile.sample_frames_per_packet()); + } + assert!(matches!( + transport_rx.try_recv(), + Err(mpsc::error::TryRecvError::Empty) + )); +} + +#[test] +fn outgoing_link_attempt_promotes_after_proof_and_drives_available_signal() { + let local_identity = Identity::new(); + let remote_identity = Identity::new(); + let remote_signing_key = remote_identity.get_signing_key().unwrap(); + let remote_public_key = remote_identity.get_public_key(); + let remote_hash = remote_identity.hash; + let destination_hash = telephony_destination_hash(&remote_hash); + + let (transport_tx, mut transport_rx) = mpsc::channel(8); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let mut core = TelephonyRuntimeCore::new(); + + let link_id = endpoint + .begin_outgoing_link_with_remote_pubkey( + &mut core, + remote_hash, + remote_public_key, + Some(Profile::LatencyLow), + 1, + ) + .unwrap(); + + let request_path = transport_rx.try_recv().unwrap(); + let TransportMessage::RequestPath { + destination_hash: requested_hash, + } = request_path + else { + panic!("expected RequestPath, got {request_path:?}"); + }; + assert_eq!(requested_hash, destination_hash); + + let register_link = transport_rx.try_recv().unwrap(); + let TransportMessage::RegisterDestination { + hash: registered_link_id, + delivery_tx: Some(link_event_tx), + .. + } = register_link + else { + panic!("expected link RegisterDestination, got {register_link:?}"); + }; + assert_eq!(registered_link_id, link_id); + + let (request_header, request_data) = take_outbound(&mut transport_rx); + assert_eq!(request_header.destination_hash, destination_hash); + assert_eq!( + request_header.flags.packet_type, + rns_wire::flags::PacketType::LinkRequest + ); + + let (mut responder, proof_data) = + Link::new_responder(&request_data, &remote_signing_key, destination_hash, 1).unwrap(); + link_event_tx + .try_send(DestinationEvent::InboundPacket { + raw: Bytes::from(link_proof_packet(link_id, &proof_data)), + interface_id: 1, + }) + .unwrap(); + + let driven = endpoint.try_drive_once(&mut core).unwrap().unwrap(); + assert_eq!(driven.step.commands, Vec::new()); + assert_eq!(driven.effects, Vec::new()); + assert!(endpoint.outgoing_attempts.is_empty()); + assert!(endpoint.outgoing_links.contains_key(&link_id)); + + let (rtt_header, rtt_data) = take_outbound(&mut transport_rx); + assert_eq!(rtt_header.context, rns_wire::context::PacketContext::Lrrtt); + responder.receive_rtt_packet(&rtt_data).unwrap(); + + link_event_tx + .try_send(DestinationEvent::InboundPacket { + raw: Bytes::from(link_data_packet( + link_id, + rns_wire::context::PacketContext::Keepalive, + &[rns_link::constants::KEEPALIVE_RESPONSE], + )), + interface_id: 1, + }) + .unwrap(); + assert!(endpoint.try_drive_once(&mut core).unwrap().is_none()); + + let available = signalling_packet(Signal::from(SignallingStatus::Available)) + .encode() + .unwrap(); + let encrypted_available = responder.encrypt(&available).unwrap(); + link_event_tx + .try_send(DestinationEvent::InboundPacket { + raw: Bytes::from(link_data_packet( + link_id, + rns_wire::context::PacketContext::None, + &encrypted_available, + )), + interface_id: 1, + }) + .unwrap(); + + let driven = endpoint.try_drive_once(&mut core).unwrap().unwrap(); + assert_eq!( + driven.step.commands, + vec![TelephonyCommand::IdentifyLocalIdentity { link_id }] + ); + assert_eq!( + driven.effects, + vec![TelephonyCommandEffect::QueuedLinkPacket { + link_id, + kind: QueuedLinkPacketKind::LinkIdentify, + }] + ); + + let (identify_header, identify_data) = take_outbound(&mut transport_rx); + assert_eq!( + identify_header.context, + rns_wire::context::PacketContext::LinkIdentify + ); + assert_eq!( + responder.handle_identification(&identify_data).unwrap(), + local_identity.get_public_key() + ); + + let close_data = responder.teardown(CloseReason::DestinationClosed).unwrap(); + link_event_tx + .try_send(DestinationEvent::InboundPacket { + raw: Bytes::from(link_data_packet( + link_id, + rns_wire::context::PacketContext::LinkClose, + &close_data, + )), + interface_id: 1, + }) + .unwrap(); + + let driven = endpoint.try_drive_once(&mut core).unwrap().unwrap(); + assert_eq!( + driven.step.commands, + vec![ + TelephonyCommand::StopAudioPipelines { link_id }, + TelephonyCommand::CallTerminated { + link_id, + reason: None, + }, + ] + ); + assert_eq!(driven.effects, vec![TelephonyCommandEffect::Noop; 2]); + assert!(endpoint.outgoing_links.is_empty()); + + let deregister_link = transport_rx.try_recv().unwrap(); + let TransportMessage::DeregisterDestination { + hash: deregistered_hash, + } = deregister_link + else { + panic!("expected DeregisterDestination, got {deregister_link:?}"); + }; + assert_eq!(deregistered_hash, link_id); +} + +#[test] +fn execute_send_signal_queues_lxst_link_packet() { + let (mut sender, receiver) = active_link_pair(); + let link_id = sender.link_id; + let identity = Identity::new(); + let signing_key = identity.get_signing_key().unwrap(); + let (tx, mut rx) = mpsc::channel(1); + + assert_eq!( + execute_command_with_link( + &tx, + &identity.get_public_key(), + &signing_key, + &mut sender, + &TelephonyCommand::SendSignal { + link_id, + signal: Signal::from(SignallingStatus::Available), + }, + ) + .unwrap(), + TelephonyCommandEffect::QueuedLinkPacket { + link_id, + kind: QueuedLinkPacketKind::LxstData, + } + ); + + let (header, encrypted) = take_outbound(&mut rx); + assert_eq!(header.context, rns_wire::context::PacketContext::None); + assert_eq!(header.destination_hash, link_id); + let plaintext = receiver.decrypt(&encrypted).unwrap(); + assert_eq!( + LxstPacket::decode(&plaintext).unwrap().signals, + vec![Signal::from(SignallingStatus::Available)] + ); +} + +#[test] +fn execute_identify_queues_verifiable_link_identify_packet() { + let (mut sender, mut receiver) = active_link_pair(); + let link_id = sender.link_id; + let identity = Identity::new(); + let signing_key = identity.get_signing_key().unwrap(); + let (tx, mut rx) = mpsc::channel(1); + + assert_eq!( + execute_command_with_link( + &tx, + &identity.get_public_key(), + &signing_key, + &mut sender, + &TelephonyCommand::IdentifyLocalIdentity { link_id }, + ) + .unwrap(), + TelephonyCommandEffect::QueuedLinkPacket { + link_id, + kind: QueuedLinkPacketKind::LinkIdentify, + } + ); + + let (header, encrypted) = take_outbound(&mut rx); + assert_eq!( + header.context, + rns_wire::context::PacketContext::LinkIdentify + ); + assert_eq!( + receiver.handle_identification(&encrypted).unwrap(), + identity.get_public_key() + ); +} + +#[test] +fn execute_teardown_queues_link_close_packet() { + let (mut sender, mut receiver) = active_link_pair(); + let link_id = sender.link_id; + let identity = Identity::new(); + let signing_key = identity.get_signing_key().unwrap(); + let (tx, mut rx) = mpsc::channel(1); + + assert_eq!( + execute_command_with_link( + &tx, + &identity.get_public_key(), + &signing_key, + &mut sender, + &TelephonyCommand::TeardownLink { link_id }, + ) + .unwrap(), + TelephonyCommandEffect::QueuedLinkPacket { + link_id, + kind: QueuedLinkPacketKind::LinkClose, + } + ); + assert_eq!(sender.state, LinkState::Closed); + + let (header, encrypted) = take_outbound(&mut rx); + assert_eq!(header.context, rns_wire::context::PacketContext::LinkClose); + assert!(receiver.receive_teardown(&encrypted)); + assert_eq!(receiver.state, LinkState::Closed); +} + +#[test] +fn outgoing_link_ignores_unauthenticated_remote_close() { + let (sender, _receiver) = active_link_pair(); + let link_id = sender.link_id; + let local_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (event_tx, event_rx) = mpsc::channel(1); + endpoint.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: sender, + event_rx, + }, + ); + + event_tx + .try_send(DestinationEvent::InboundPacket { + raw: Bytes::from(link_data_packet( + link_id, + rns_wire::context::PacketContext::LinkClose, + &[0xA5; 16], + )), + interface_id: 1, + }) + .unwrap(); + + assert!(endpoint.try_recv_link_event().unwrap().is_none()); + let state = endpoint.outgoing_links.get(&link_id).unwrap(); + assert_eq!(state.link.state, LinkState::Active); + assert!(matches!( + transport_rx.try_recv(), + Err(mpsc::error::TryRecvError::Empty) + )); +} + +#[test] +fn outgoing_link_closes_on_authenticated_remote_close() { + let (sender, mut receiver) = active_link_pair(); + let link_id = sender.link_id; + let local_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (event_tx, event_rx) = mpsc::channel(1); + endpoint.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: sender, + event_rx, + }, + ); + let close_data = receiver.teardown(CloseReason::DestinationClosed).unwrap(); + + event_tx + .try_send(DestinationEvent::InboundPacket { + raw: Bytes::from(link_data_packet( + link_id, + rns_wire::context::PacketContext::LinkClose, + &close_data, + )), + interface_id: 1, + }) + .unwrap(); + + assert_eq!( + endpoint.try_recv_link_event().unwrap(), + Some(TelephonyLinkEvent::LinkClosed { link_id }) + ); + assert!(endpoint.outgoing_links.is_empty()); + assert_deregistered_link(&mut transport_rx, link_id); +} + +#[test] +fn outgoing_active_transport_close_deregisters_link_destination() { + let (sender, _receiver) = active_link_pair(); + let link_id = sender.link_id; + let local_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (event_tx, event_rx) = mpsc::channel(1); + endpoint.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: sender, + event_rx, + }, + ); + + event_tx + .try_send(DestinationEvent::LinkClosed { link_id }) + .unwrap(); + + assert_eq!( + endpoint.try_recv_link_event().unwrap(), + Some(TelephonyLinkEvent::LinkClosed { link_id }) + ); + assert!(endpoint.outgoing_links.is_empty()); + assert_deregistered_link(&mut transport_rx, link_id); +} + +#[test] +fn outgoing_attempt_transport_close_deregisters_link_destination() { + let local_identity = Identity::new(); + let remote_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (event_tx, event_rx) = mpsc::channel(1); + let (link, _request_data) = Link::new_initiator(link(0xA4), 1); + let link_id = link.link_id; + endpoint.outgoing_attempts.insert( + link_id, + OutgoingLinkAttempt { + link, + remote_public_key: remote_identity.get_public_key(), + event_rx, + }, + ); + + event_tx + .try_send(DestinationEvent::LinkClosed { link_id }) + .unwrap(); + + assert_eq!( + endpoint.try_recv_link_event().unwrap(), + Some(TelephonyLinkEvent::LinkClosed { link_id }) + ); + assert!(endpoint.outgoing_attempts.is_empty()); + assert_deregistered_link(&mut transport_rx, link_id); +} + +#[test] +fn outgoing_attempt_ignores_shared_instance_announce_request() { + let local_identity = Identity::new(); + let remote_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (event_tx, event_rx) = mpsc::channel(1); + let (link, _request_data) = Link::new_initiator(link(0xA6), 1); + let link_id = link.link_id; + endpoint.outgoing_attempts.insert( + link_id, + OutgoingLinkAttempt { + link, + remote_public_key: remote_identity.get_public_key(), + event_rx, + }, + ); + + event_tx + .try_send(DestinationEvent::AnnounceRequested( + rns_transport::link_messages::AnnounceRequest::normal( + TELEPHONY_DESTINATION_NAME.to_string(), + ), + )) + .unwrap(); + + assert_eq!(endpoint.try_recv_link_event().unwrap(), None); + assert!(endpoint.outgoing_attempts.contains_key(&link_id)); + assert!(transport_rx.try_recv().is_err()); +} + +#[test] +fn outgoing_active_ignores_broadcast_delivery_proof() { + let (sender, _receiver) = active_link_pair(); + let link_id = sender.link_id; + let local_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (event_tx, event_rx) = mpsc::channel(1); + endpoint.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: sender, + event_rx, + }, + ); + + event_tx + .try_send(DestinationEvent::DeliveryProof { + msg_id: "lxmf-proof".to_string(), + rtt: None, + }) + .unwrap(); + + assert_eq!(endpoint.try_recv_link_event().unwrap(), None); + assert!(endpoint.outgoing_links.contains_key(&link_id)); + assert!(transport_rx.try_recv().is_err()); +} + +#[test] +fn outgoing_active_ignores_inbound_link_request_event() { + let (sender, _receiver) = active_link_pair(); + let link_id = sender.link_id; + let local_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (event_tx, event_rx) = mpsc::channel(1); + endpoint.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: sender, + event_rx, + }, + ); + + event_tx + .try_send(DestinationEvent::LinkRequest { + raw: Bytes::from_static(&[0x01, 0x02, 0x03]), + interface_id: 1, + }) + .unwrap(); + + assert_eq!(endpoint.try_recv_link_event().unwrap(), None); + assert!(endpoint.outgoing_links.contains_key(&link_id)); + assert!(transport_rx.try_recv().is_err()); +} + +#[test] +fn outgoing_active_ignores_packet_for_other_destination() { + let (sender, _receiver) = active_link_pair(); + let link_id = sender.link_id; + let local_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (event_tx, event_rx) = mpsc::channel(1); + endpoint.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: sender, + event_rx, + }, + ); + + event_tx + .try_send(DestinationEvent::InboundPacket { + raw: Bytes::from(link_data_packet( + link(0xE7), + rns_wire::context::PacketContext::None, + &[0x99], + )), + interface_id: 1, + }) + .unwrap(); + + assert_eq!(endpoint.try_recv_link_event().unwrap(), None); + assert!(endpoint.outgoing_links.contains_key(&link_id)); + assert!(transport_rx.try_recv().is_err()); +} + +#[test] +fn endpoint_teardown_of_outgoing_link_deregisters_link_destination() { + let (sender, _receiver) = active_link_pair(); + let link_id = sender.link_id; + let local_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (_event_tx, event_rx) = mpsc::channel(1); + endpoint.outgoing_links.insert( + link_id, + OutgoingLinkState { + link: sender, + event_rx, + }, + ); + + assert_eq!( + endpoint + .execute_command(&TelephonyCommand::TeardownLink { link_id }) + .unwrap(), + TelephonyCommandEffect::QueuedLinkPacket { + link_id, + kind: QueuedLinkPacketKind::LinkClose, + } + ); + assert!(endpoint.outgoing_links.is_empty()); + + let (close_header, close_data) = take_outbound(&mut transport_rx); + assert_eq!( + close_header.context, + rns_wire::context::PacketContext::LinkClose + ); + assert!(!close_data.is_empty()); + + assert_deregistered_link(&mut transport_rx, link_id); +} + +#[test] +fn endpoint_teardown_of_outgoing_attempt_deregisters_link_destination() { + let local_identity = Identity::new(); + let remote_identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(4); + let mut endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _listener_registration = transport_rx.try_recv().unwrap(); + let (_event_tx, event_rx) = mpsc::channel(1); + let (link, _request_data) = Link::new_initiator(link(0xA8), 1); + let link_id = link.link_id; + endpoint.outgoing_attempts.insert( + link_id, + OutgoingLinkAttempt { + link, + remote_public_key: remote_identity.get_public_key(), + event_rx, + }, + ); + + assert_eq!( + endpoint + .execute_command(&TelephonyCommand::TeardownLink { link_id }) + .unwrap(), + TelephonyCommandEffect::Noop + ); + assert!(endpoint.outgoing_attempts.is_empty()); + assert_deregistered_link(&mut transport_rx, link_id); +} + +#[test] +fn rns_endpoint_requests_path_to_outgoing_telephony_destination() { + let local_identity = Identity::new(); + let remote = identity(0x81); + let (transport_tx, mut transport_rx) = mpsc::channel(2); + let endpoint = TelephonyRnsEndpoint::register(transport_tx, &local_identity).unwrap(); + let _registered = transport_rx.try_recv().unwrap(); + + endpoint.request_path_to_identity(&remote).unwrap(); + let requested = transport_rx.try_recv().unwrap(); + let TransportMessage::RequestPath { destination_hash } = requested else { + panic!("expected RequestPath, got {requested:?}"); + }; + + assert_eq!(destination_hash, telephony_destination_hash(&remote)); +} + +#[test] +fn rns_endpoint_deregisters_telephony_destination_on_teardown() { + let identity = Identity::new(); + let (transport_tx, mut transport_rx) = mpsc::channel(2); + let endpoint = TelephonyRnsEndpoint::register(transport_tx, &identity).unwrap(); + let _registered = transport_rx.try_recv().unwrap(); + + endpoint.deregister_destination().unwrap(); + let deregistered = transport_rx.try_recv().unwrap(); + let TransportMessage::DeregisterDestination { hash } = deregistered else { + panic!("expected DeregisterDestination, got {deregistered:?}"); + }; + + assert_eq!(hash, endpoint.destination_hash); +} + +#[test] +fn rns_endpoint_reports_transport_backpressure() { + let identity = Identity::new(); + let (transport_tx, _transport_rx) = mpsc::channel(1); + transport_tx + .try_send(TransportMessage::Shutdown) + .expect("pre-fill transport queue"); + + assert!(matches!( + TelephonyRnsEndpoint::register(transport_tx, &identity), + Err(Error::TransportFull) + )); +} diff --git a/crates/lxst-telephony/tests/python_destination_parity.rs b/crates/lxst-telephony/tests/python_destination_parity.rs new file mode 100644 index 0000000..233830d --- /dev/null +++ b/crates/lxst-telephony/tests/python_destination_parity.rs @@ -0,0 +1,69 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use lxst_core::TELEPHONY_DESTINATION_NAME; +use lxst_telephony::telephony_destination_hash; +use serde_json::Value; + +const SKIP_ENV: &str = "SKIP_PYTHON_LXST_INTEROP"; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("crate is under rsLXST/crates/lxst-telephony") + .to_path_buf() +} + +fn fixture_script() -> PathBuf { + repo_root().join("tools/fixtures/lxst_destination_fixtures.py") +} + +fn should_skip() -> bool { + std::env::var(SKIP_ENV).map(|v| v == "1").unwrap_or(false) +} + +#[test] +fn rust_destination_hash_matches_python_rns_lxst_telephony() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping Python RNS destination parity"); + return; + } + + let output = Command::new("python3") + .arg(fixture_script()) + .output() + .expect("spawn Python destination fixture generator"); + + assert!( + output.status.success(), + "Python destination fixture generator failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let fixture: Value = serde_json::from_slice(&output.stdout).expect("fixture JSON"); + assert_eq!( + fixture["expanded_name"].as_str().expect("expanded name"), + TELEPHONY_DESTINATION_NAME + ); + assert_eq!( + fixture["destination_hash"] + .as_str() + .expect("destination hash"), + fixture["hash_from_name"].as_str().expect("hash from name") + ); + + let identity_hash = hex::decode(fixture["identity_hash"].as_str().expect("identity hash")) + .expect("identity hash hex"); + let identity_hash: [u8; 16] = identity_hash + .try_into() + .expect("Python RNS identity hashes are 16 bytes"); + + assert_eq!( + hex::encode(telephony_destination_hash(&identity_hash)), + fixture["destination_hash"] + .as_str() + .expect("destination hash") + ); +} diff --git a/crates/lxst-telephony/tests/python_telephone_helper.rs b/crates/lxst-telephony/tests/python_telephone_helper.rs new file mode 100644 index 0000000..4893f09 --- /dev/null +++ b/crates/lxst-telephony/tests/python_telephone_helper.rs @@ -0,0 +1,110 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use lxst_telephony::telephony_destination_hash; +use serde_json::Value; + +const SKIP_ENV: &str = "SKIP_PYTHON_LXST_INTEROP"; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("crate is under rsLXST/crates/lxst-telephony") + .to_path_buf() +} + +fn helper_script() -> PathBuf { + repo_root().join("tools/interop/lxst_telephone_helper.py") +} + +fn temp_storage(name: &str) -> PathBuf { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("{name}-{}-{now}", std::process::id())) +} + +fn should_skip() -> bool { + std::env::var(SKIP_ENV).map(|v| v == "1").unwrap_or(false) +} + +#[test] +fn python_telephone_helper_loads_reference_and_creates_destination() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping Python LXST Telephone helper self-test"); + return; + } + + let storage = temp_storage("rs-lxst-python-telephone-helper"); + let output = Command::new("python3") + .arg(helper_script()) + .arg("--mode") + .arg("self-test") + .arg("--storage-dir") + .arg(&storage) + .output() + .expect("spawn Python LXST Telephone helper"); + + let _ = fs::remove_dir_all(&storage); + + assert!( + output.status.success(), + "Python LXST Telephone helper failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let events: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .map(|line| serde_json::from_str(line).expect("helper JSON line")) + .collect(); + + let ready = events + .iter() + .find(|event| event["event"] == "READY") + .expect("READY event"); + assert_eq!(ready["app_name"], "lxst"); + assert_eq!(ready["primitive_name"], "telephony"); + assert_eq!(ready["lxst_version"], "0.4.5"); + assert_eq!( + ready["lxst_root"].as_str(), + Some("/Users/Games/Desktop/main/upstream/LXST") + ); + assert!(ready["codec2_stubbed"].is_boolean()); + assert_eq!(ready["headless_audio"], true); + assert_eq!(ready["native_filters_disabled"], true); + + let identity_hash = hex::decode(ready["identity_hash"].as_str().expect("identity hash")) + .expect("identity hash hex"); + let identity_hash: [u8; 16] = identity_hash + .try_into() + .expect("Python RNS identity hashes are 16 bytes"); + assert_eq!( + hex::encode(telephony_destination_hash(&identity_hash)), + ready["destination_hash"] + .as_str() + .expect("destination hash") + ); + assert_eq!( + ready["expanded_name"].as_str().expect("expanded name"), + format!("lxst.telephony.{}", hex::encode(identity_hash)) + ); + + let snapshot = events + .iter() + .find(|event| event["event"] == "SNAPSHOT") + .expect("SNAPSHOT event"); + assert_eq!(snapshot["active_call"], Value::Null); + assert_eq!(snapshot["busy"], false); + assert_eq!(snapshot["call_status"], 3); + assert_eq!(snapshot["link_count"], 0); + + assert!( + events.iter().any(|event| event["event"] == "STOPPED"), + "helper did not stop cleanly" + ); +} diff --git a/crates/lxst-telephony/tests/python_telephone_live_interop.rs b/crates/lxst-telephony/tests/python_telephone_live_interop.rs new file mode 100644 index 0000000..42efbc2 --- /dev/null +++ b/crates/lxst-telephony/tests/python_telephone_live_interop.rs @@ -0,0 +1,2965 @@ +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use bytes::Bytes; +use rns_identity::identity::Identity; +use rns_identity::name_hash::name_hash; +use rns_interface::tcp::{TcpClientConfig, TcpServerConfig, spawn_tcp_client, spawn_tcp_server}; +use rns_transport::actor::TransportActor; +use rns_transport::constants::{ANNOUNCE_CAP, InterfaceDirection as TransportInterfaceDirection}; +use rns_transport::ingress::IngressController; +use rns_transport::messages::{ + AnnounceHandlerEvent, InterfaceEntry, InterfaceRole, OutboundRequest, TransportMessage, +}; +use serde_json::{Value, json}; +use tokio::sync::mpsc; + +use lxst_core::{ + CallRole, FrameStreamEvent, Profile, RawAudioFrame, RawBitDepth, SignallingStatus, + SyntheticSource, SyntheticSourceKind, TELEPHONY_DESTINATION_NAME, +}; +use lxst_telephony::{ + ActiveCallSnapshot, TelephonyCommand, TelephonyControl, TelephonyDriveStep, + TelephonyRnsEndpoint, TelephonyRuntimeCore, TelephonyService, TelephonyServiceConfig, + TelephonyServiceEvent, telephony_inbound_destination, +}; + +const SKIP_ENV: &str = "SKIP_PYTHON_LXST_INTEROP"; +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("crate is under rsLXST/crates/lxst-telephony") + .to_path_buf() +} + +fn helper_script() -> PathBuf { + repo_root().join("tools/interop/lxst_telephone_helper.py") +} + +fn should_skip() -> bool { + std::env::var(SKIP_ENV).map(|v| v == "1").unwrap_or(false) +} + +fn temp_storage(name: &str) -> PathBuf { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is after Unix epoch") + .as_nanos(); + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("{name}-{}-{now}-{counter}", std::process::id())) +} + +fn free_tcp_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind free TCP port"); + let port = listener.local_addr().expect("local addr").port(); + drop(listener); + port +} + +struct PythonTelephoneHost { + child: Child, + stdin: ChildStdin, + events: std::sync::mpsc::Receiver, + stashed_events: Mutex>, + storage: PathBuf, + log_path: PathBuf, +} + +impl PythonTelephoneHost { + fn spawn(port: u16) -> Self { + Self::spawn_with_tcp(port, "client", false) + } + + fn spawn_transport_hub(port: u16) -> Self { + Self::spawn_with_tcp(port, "server", true) + } + + fn spawn_with_tcp(port: u16, tcp_role: &str, enable_transport: bool) -> Self { + let storage = temp_storage("rs-lxst-python-telephone-live"); + std::fs::create_dir_all(&storage).expect("create Python helper storage"); + let log_path = storage.join("helper.stderr.log"); + let log_file = std::fs::File::create(&log_path).expect("create helper stderr log"); + + let mut command = Command::new("python3"); + command + .arg(helper_script()) + .arg("--mode") + .arg("host") + .arg("--storage-dir") + .arg(&storage) + .arg("--tcp-role") + .arg(tcp_role) + .arg("--tcp-host") + .arg("127.0.0.1") + .arg("--tcp-port") + .arg(port.to_string()) + .arg("--ring-time") + .arg("20") + .arg("--wait-time") + .arg("20") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::from(log_file)); + if enable_transport { + command.arg("--enable-transport"); + } + + let mut child = command.spawn().expect("spawn Python LXST Telephone helper"); + + let stdout = child.stdout.take().expect("helper stdout"); + let stdin = child.stdin.take().expect("helper stdin"); + let (event_tx, events) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines() { + let line = line.expect("read helper stdout"); + let event: Value = serde_json::from_str(&line).expect("helper JSON line"); + if event_tx.send(event).is_err() { + break; + } + } + }); + + Self { + child, + stdin, + events, + stashed_events: Mutex::new(Vec::new()), + storage, + log_path, + } + } + + fn send(&mut self, command: Value) { + writeln!(self.stdin, "{command}").expect("write helper command"); + self.stdin.flush().expect("flush helper command"); + } + + fn try_wait_event(&self, name: &str, timeout: Duration) -> Option { + { + let mut stashed = self.stashed_events.lock().expect("event stash"); + if let Some(index) = stashed.iter().position(|event| event["event"] == name) { + return Some(stashed.remove(index)); + } + } + + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + match self + .events + .recv_timeout(remaining.min(Duration::from_millis(250))) + { + Ok(event) if event["event"] == name => return Some(event), + Ok(event) if event["event"] == "FATAL" || event["event"] == "ERROR" => { + panic!("Python helper emitted failure event: {event}") + } + Ok(event) => self.stashed_events.lock().expect("event stash").push(event), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + panic!("Python helper stdout closed before {name}") + } + } + } + None + } + + fn wait_event(&self, name: &str, timeout: Duration) -> Value { + if let Some(event) = self.try_wait_event(name, timeout) { + return event; + } + + panic!( + "timed out waiting for Python helper event {name}; stashed events:\n{}\nstderr:\n{}", + serde_json::to_string_pretty(&*self.stashed_events.lock().expect("event stash")) + .unwrap_or_default(), + self.stderr_log() + ); + } + + fn stderr_log(&self) -> String { + std::fs::read_to_string(&self.log_path).unwrap_or_default() + } +} + +impl Drop for PythonTelephoneHost { + fn drop(&mut self) { + let _ = writeln!(self.stdin, "{}", json!({"cmd": "shutdown"})); + let _ = self.stdin.flush(); + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.storage); + } +} + +async fn spawn_rust_actor_and_tcp( + port: u16, +) -> ( + mpsc::Sender, + rns_interface::traits::InterfaceHandle, + mpsc::Receiver, +) { + let (actor, actor_tx) = TransportActor::new(); + tokio::spawn(actor.run()); + + let (handle_tx, handle_rx) = mpsc::channel(8); + let id_gen = Arc::new(AtomicU64::new(9_000)); + let server_id = id_gen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let cfg = TcpServerConfig::new("rs-lxst-python-telephone-live", "127.0.0.1", port); + let server = spawn_tcp_server(cfg, server_id, id_gen, actor_tx.clone(), handle_tx) + .await + .expect("spawn Rust TCP server"); + + (actor_tx, server, handle_rx) +} + +async fn spawn_rust_actor_and_tcp_client( + name: &str, + port: u16, + interface_id: u64, +) -> (mpsc::Sender, tokio::task::JoinHandle<()>) { + let actor_tx = spawn_rust_actor().await; + let read_task = attach_rust_tcp_client(actor_tx.clone(), name, port, interface_id).await; + + (actor_tx, read_task) +} + +async fn spawn_rust_actor() -> mpsc::Sender { + let (actor, actor_tx) = TransportActor::new(); + tokio::spawn(actor.run()); + actor_tx +} + +async fn attach_rust_tcp_client( + actor_tx: mpsc::Sender, + name: &str, + port: u16, + interface_id: u64, +) -> tokio::task::JoinHandle<()> { + let mut cfg = TcpClientConfig::new(name, "127.0.0.1", port); + cfg.connect_timeout_secs = 1; + cfg.max_reconnect_tries = Some(5); + + let handle = spawn_tcp_client(cfg, interface_id, actor_tx.clone(), None) + .await + .expect("spawn Rust TCP client"); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while !handle.online.load(Ordering::SeqCst) { + assert!( + tokio::time::Instant::now() < deadline, + "Rust TCP client {name} did not connect to Python transport hub" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let (id, entry, read_task) = register_interface_entry(handle); + actor_tx + .send(TransportMessage::RegisterInterface { id, entry }) + .await + .expect("register Rust TCP client interface"); + + read_task +} + +fn register_interface_entry( + handle: rns_interface::traits::InterfaceHandle, +) -> (u64, InterfaceEntry, tokio::task::JoinHandle<()>) { + let mode = match handle.mode { + rns_interface::traits::InterfaceMode::AccessPoint => { + rns_transport::constants::InterfaceMode::AccessPoint + } + rns_interface::traits::InterfaceMode::Roaming => { + rns_transport::constants::InterfaceMode::Roaming + } + rns_interface::traits::InterfaceMode::Boundary => { + rns_transport::constants::InterfaceMode::Boundary + } + rns_interface::traits::InterfaceMode::Gateway => { + rns_transport::constants::InterfaceMode::Gateway + } + _ => rns_transport::constants::InterfaceMode::Gateway, + }; + let entry = InterfaceEntry { + name: handle.name, + mode, + role: InterfaceRole::Normal, + direction: TransportInterfaceDirection { + inbound: handle.direction.inbound, + outbound: handle.direction.outbound, + }, + bitrate: handle.bitrate, + mtu: handle.mtu, + tx: handle.tx, + ifac_key: None, + ifac_size: 0, + announce_cap: ANNOUNCE_CAP, + announce_allowed_at: 0.0, + announce_rate_target: None, + announce_rate_grace: None, + announce_rate_penalty: None, + online: Some(handle.online), + rxb: handle.rxb, + txb: handle.txb, + tx_drops: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), + ingress: IngressController::new(), + announce_queue: Vec::new(), + }; + (handle.id, entry, handle.read_task) +} + +async fn register_telephony_announce_handler( + actor_tx: &mpsc::Sender, +) -> mpsc::Receiver { + let (callback_tx, callback_rx) = mpsc::channel(16); + actor_tx + .send(TransportMessage::RegisterAnnounceHandler { + aspect_filter: Some(TELEPHONY_DESTINATION_NAME.to_string()), + receive_path_responses: true, + callback_tx, + }) + .await + .expect("register lxst.telephony announce handler"); + callback_rx +} + +fn hash_from_ready(ready: &Value, field: &str) -> [u8; 16] { + let bytes = hex::decode(ready[field].as_str().expect(field)).expect("hex hash"); + bytes + .try_into() + .expect("Reticulum identity/destination hashes are 16 bytes") +} + +async fn send_rust_telephony_announce( + actor_tx: &mpsc::Sender, + identity: &Identity, +) -> [u8; 16] { + let mut destination = + telephony_inbound_destination(identity).expect("create Rust Telephone destination"); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is after Unix epoch") + .as_secs_f64(); + let raw = destination + .announce_packet(identity, None, None, false, None, now) + .expect("build Rust Telephone announce"); + actor_tx + .send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: destination.hash, + })) + .await + .expect("send Rust Telephone announce"); + destination.hash +} + +struct RustHubNode { + actor_tx: mpsc::Sender, + identity: Identity, + destination_hash: [u8; 16], + read_task: Option>, + service_task: tokio::task::JoinHandle<()>, + control_tx: mpsc::Sender, + event_rx: mpsc::Receiver, +} + +impl RustHubNode { + async fn spawn(name: &str, port: u16, interface_id: u64) -> Self { + let (actor_tx, read_task) = spawn_rust_actor_and_tcp_client(name, port, interface_id).await; + Self::spawn_with_actor( + actor_tx, + Some(read_task), + TelephonyServiceConfig { + poll_interval: Duration::from_millis(20), + incoming_ring_timeout: Some(Duration::from_secs(15)), + outgoing_call_timeout: Some(Duration::from_secs(15)), + media_frames_per_tick: 4, + ..TelephonyServiceConfig::default() + }, + ) + .await + } + + async fn spawn_before_tcp() -> Self { + let actor_tx = spawn_rust_actor().await; + Self::spawn_with_actor( + actor_tx, + None, + TelephonyServiceConfig { + poll_interval: Duration::from_millis(20), + incoming_ring_timeout: Some(Duration::from_secs(15)), + outgoing_call_timeout: Some(Duration::from_secs(15)), + media_frames_per_tick: 4, + startup_announce_retry_interval: Some(Duration::from_millis(100)), + startup_announce_retries: 10, + announce_interval: None, + ..TelephonyServiceConfig::default() + }, + ) + .await + } + + async fn spawn_with_actor( + actor_tx: mpsc::Sender, + read_task: Option>, + config: TelephonyServiceConfig, + ) -> Self { + let identity = Identity::new(); + let endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &identity) + .expect("register Rust LXST Telephone endpoint"); + let destination_hash = endpoint.destination_hash; + let (control_tx, control_rx) = mpsc::channel(16); + let (event_tx, event_rx) = mpsc::channel(64); + let service = TelephonyService::with_config( + endpoint, + TelephonyRuntimeCore::new(), + control_rx, + event_tx, + config, + ); + let service_task = tokio::spawn(service.run()); + + Self { + actor_tx, + identity, + destination_hash, + read_task, + service_task, + control_tx, + event_rx, + } + } + + async fn attach_tcp(&mut self, name: &str, port: u16, interface_id: u64) { + assert!( + self.read_task.is_none(), + "RustHubNode TCP client is already attached" + ); + self.read_task = + Some(attach_rust_tcp_client(self.actor_tx.clone(), name, port, interface_id).await); + } + + async fn shutdown(mut self) { + let _ = self.control_tx.send(TelephonyControl::Shutdown).await; + let _ = wait_matching_service_event( + &mut self.event_rx, + Duration::from_secs(5), + "service stopped", + |event| matches!(event, TelephonyServiceEvent::Stopped), + ) + .await; + let _ = self.service_task.await; + if let Some(read_task) = self.read_task { + read_task.abort(); + } + } +} + +async fn wait_for_announce( + announce_rx: &mut mpsc::Receiver, + destination_hash: [u8; 16], + timeout: Duration, + label: &str, +) -> AnnounceHandlerEvent { + tokio::time::timeout(timeout, async { + while let Some(event) = announce_rx.recv().await { + if event.destination_hash == destination_hash { + return event; + } + } + panic!("announce handler channel closed before {label}"); + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for announce {label}")) +} + +fn drive_steps_contain_call_termination( + steps: &[TelephonyDriveStep], + expected_reason: Option, +) -> bool { + steps.iter().any(|driven| { + driven.step.commands.iter().any(|command| { + matches!( + command, + TelephonyCommand::CallTerminated { reason, .. } if *reason == expected_reason + ) + }) + }) +} + +async fn drive_until_active_status( + endpoint: &mut TelephonyRnsEndpoint, + core: &mut TelephonyRuntimeCore, + helper: &PythonTelephoneHost, + status: SignallingStatus, + timeout: Duration, + context: &str, +) -> ActiveCallSnapshot { + let deadline = tokio::time::Instant::now() + timeout; + loop { + endpoint + .try_drive_ready(core) + .expect("drive Rust Telephone endpoint"); + + if let Some(call) = core.snapshot().active_call { + if call.status == status { + return call; + } + } + + assert!( + tokio::time::Instant::now() < deadline, + "{context}; helper stderr:\n{}", + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn drive_until_call_cleared( + endpoint: &mut TelephonyRnsEndpoint, + core: &mut TelephonyRuntimeCore, + helper: &PythonTelephoneHost, + expected_reason: Option, + timeout: Duration, + context: &str, +) { + let deadline = tokio::time::Instant::now() + timeout; + let mut saw_expected_termination = false; + + loop { + let steps = endpoint + .try_drive_ready(core) + .expect("drive Rust Telephone endpoint"); + saw_expected_termination |= drive_steps_contain_call_termination(&steps, expected_reason); + + if core.snapshot().active_call.is_none() && saw_expected_termination { + return; + } + + assert!( + tokio::time::Instant::now() < deadline, + "{context}; saw expected termination: {saw_expected_termination}; snapshot: {:?}; helper stderr:\n{}", + core.snapshot(), + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_service_event( + event_rx: &mut mpsc::Receiver, + timeout: Duration, + label: &str, +) -> TelephonyServiceEvent { + tokio::time::timeout(timeout, event_rx.recv()) + .await + .unwrap_or_else(|_| panic!("timed out waiting for service event {label}")) + .unwrap_or_else(|| panic!("service event channel closed before {label}")) +} + +async fn wait_matching_service_event( + event_rx: &mut mpsc::Receiver, + timeout: Duration, + label: &str, + matches: impl Fn(&TelephonyServiceEvent) -> bool, +) -> TelephonyServiceEvent { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let now = tokio::time::Instant::now(); + assert!( + now < deadline, + "timed out waiting for service event {label}" + ); + let event = wait_service_event(event_rx, deadline - now, label).await; + if matches(&event) { + return event; + } + } +} + +fn synthetic_frame_for_profile(profile: Profile) -> RawAudioFrame { + SyntheticSource::new( + profile.channels(), + profile.sample_rate_hz(), + profile.sample_frames_per_packet(), + SyntheticSourceKind::Sine { + frequency_hz: 440.0, + amplitude: 0.25, + }, + ) + .unwrap() + .next_raw_frame() + .unwrap() +} + +const OPUS_VOICE_PROFILES: [Profile; 5] = [ + Profile::QualityMedium, + Profile::QualityHigh, + Profile::QualityMax, + Profile::LatencyLow, + Profile::LatencyUltraLow, +]; +const PYTHON_HEADLESS_AUDIO_SAMPLE_RATE_HZ: usize = 48_000; +const PYTHON_HEADLESS_AUDIO_CHANNELS: usize = 1; + +fn python_opus_profile(profile: Profile) -> u8 { + match profile { + Profile::QualityMedium | Profile::LatencyLow | Profile::LatencyUltraLow => 0x01, + Profile::QualityHigh => 0x02, + Profile::QualityMax => 0x03, + Profile::BandwidthUltraLow | Profile::BandwidthVeryLow | Profile::BandwidthLow => { + panic!("{} is not an Opus telephony profile", profile.name()) + } + } +} + +fn python_preferred_profile_signal(profile: Profile) -> u64 { + 0xFF + u64::from(profile.wire_value()) +} + +fn python_headless_output_frames(profile: Profile) -> usize { + PYTHON_HEADLESS_AUDIO_SAMPLE_RATE_HZ * usize::from(profile.frame_time_ms()) / 1000 +} + +struct RustCallerSession { + profile: Profile, + actor_tx: mpsc::Sender, + rust_destination_hash: [u8; 16], + accepted_read_task: tokio::task::JoinHandle<()>, + server_read_task: tokio::task::JoinHandle<()>, + service_task: tokio::task::JoinHandle<()>, + control_tx: mpsc::Sender, + event_rx: mpsc::Receiver, + helper: PythonTelephoneHost, +} + +impl RustCallerSession { + async fn establish(profile: Profile) -> Option { + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + let mut announce_rx = register_telephony_announce_handler(&actor_tx).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + if !ready["opus_available"].as_bool().unwrap_or(false) { + eprintln!("Python LXST Opus codec unavailable -> skipping live Opus media interop"); + server.read_task.abort(); + return None; + } + let remote_destination_hash = hash_from_ready(&ready, "destination_hash"); + let remote_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + helper + .send(json!({"cmd": "announce", "id": format!("announce-{}", profile.abbreviation())})); + let announced = helper.wait_event("ANNOUNCED", Duration::from_secs(10)); + assert_eq!( + announced["destination_hash"], + hex::encode(remote_destination_hash) + ); + let announce = tokio::time::timeout(Duration::from_secs(15), announce_rx.recv()) + .await + .unwrap_or_else(|_| panic!("Rust transport never received Python announce")) + .expect("announce handler channel closed"); + assert_eq!(announce.destination_hash, remote_destination_hash); + + let local_identity = Identity::new(); + let endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let rust_destination_hash = endpoint.destination_hash; + let (control_tx, control_rx) = mpsc::channel(16); + let (event_tx, mut event_rx) = mpsc::channel(64); + let service = TelephonyService::with_config( + endpoint, + TelephonyRuntimeCore::new(), + control_rx, + event_tx, + TelephonyServiceConfig { + poll_interval: Duration::from_millis(20), + incoming_ring_timeout: Some(Duration::from_secs(10)), + outgoing_call_timeout: Some(Duration::from_secs(10)), + media_frames_per_tick: 4, + ..TelephonyServiceConfig::default() + }, + ); + let service_task = tokio::spawn(service.run()); + + control_tx + .send(TelephonyControl::Call { + remote_identity: remote_identity_hash, + profile: Some(profile), + discovery_timeout: Duration::from_secs(5), + }) + .await + .expect("send Rust Telephone service call control"); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "outgoing call started", + |event| matches!(event, TelephonyServiceEvent::OutgoingCallStarted { .. }), + ) + .await; + + helper.wait_event("RINGING", Duration::from_secs(5)); + helper.send(json!({"cmd": "answer", "id": format!("answer-{}", profile.abbreviation())})); + let answered = helper.wait_event("ANSWERED", Duration::from_secs(5)); + assert_eq!(answered["accepted"], true); + helper.wait_event("ESTABLISHED", Duration::from_secs(5)); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "established snapshot", + |event| { + matches!( + event, + TelephonyServiceEvent::Snapshot(snapshot) + if snapshot.active_call.as_ref().is_some_and(|call| { + call.status == SignallingStatus::Established + && call.profile == Some(profile) + }) + ) + }, + ) + .await; + + Some(Self { + profile, + actor_tx, + rust_destination_hash, + accepted_read_task, + server_read_task: server.read_task, + service_task, + control_tx, + event_rx, + helper, + }) + } + + async fn send_rust_opus_to_python(&mut self) -> Value { + self.control_tx + .send(TelephonyControl::SendOpusFrames { + profile: self.profile, + frames: vec![synthetic_frame_for_profile(self.profile)], + }) + .await + .expect("send Opus media frame through Rust Telephone service"); + wait_matching_service_event( + &mut self.event_rx, + Duration::from_secs(5), + "opus media sent", + |event| { + matches!( + event, + TelephonyServiceEvent::MediaSent { + frames: 1, + packets: 1, + .. + } + ) + }, + ) + .await; + + self.helper + .wait_event("MEDIA_FRAME", Duration::from_secs(5)) + } + + async fn send_python_opus_to_rust(&mut self) { + let python_frame = synthetic_frame_for_profile(self.profile); + self.helper.send(json!({ + "cmd": "send_opus_frame", + "id": format!("py-opus-{}", self.profile.abbreviation()), + "profile": python_opus_profile(self.profile), + "channels": self.profile.channels(), + "samplerate": self.profile.sample_rate_hz(), + "samples": python_frame.samples, + })); + let opus_sent = self + .helper + .wait_event("OPUS_FRAME_SENT", Duration::from_secs(5)); + assert_eq!(opus_sent["sent"], true); + + let service_decoded = wait_matching_service_event( + &mut self.event_rx, + Duration::from_secs(5), + "service decoded Opus frames", + |event| { + matches!( + event, + TelephonyServiceEvent::OpusFramesReceived { + profile: event_profile, + frames, + .. + } if *event_profile == self.profile + && frames.len() == 1 + && frames[0].channels == self.profile.channels() + && frames[0].sample_frames() == self.profile.sample_frames_per_packet() + ) + }, + ) + .await; + assert!(matches!( + service_decoded, + TelephonyServiceEvent::OpusFramesReceived { .. } + )); + } + + async fn switch_rust_profile(&mut self, profile: Profile) { + self.control_tx + .send(TelephonyControl::SwitchProfile { profile }) + .await + .expect("send Rust Telephone service profile switch control"); + wait_matching_service_event( + &mut self.event_rx, + Duration::from_secs(5), + "Rust profile switch snapshot", + |event| { + matches!( + event, + TelephonyServiceEvent::Snapshot(snapshot) + if snapshot.active_call.as_ref().is_some_and(|call| { + call.status == SignallingStatus::Established + && call.profile == Some(profile) + }) + ) + }, + ) + .await; + self.wait_python_preferred_profile_signal(profile); + self.wait_python_active_profile(profile).await; + self.profile = profile; + } + + async fn switch_python_profile(&mut self, profile: Profile) { + let expected_profile = u64::from(profile.wire_value()); + let deadline = Instant::now() + Duration::from_secs(5); + let mut attempt = 0_u64; + let switched = loop { + attempt += 1; + self.helper.send(json!({ + "cmd": "switch_profile", + "id": format!("py-switch-{}-{attempt}", profile.abbreviation()), + "profile": profile.wire_value(), + })); + let event = self + .helper + .wait_event("PROFILE_SWITCHED", Duration::from_secs(5)); + if event["profile"].as_u64() == Some(expected_profile) { + break event; + } + + assert!( + Instant::now() < deadline, + "Python helper did not switch to profile {}; last event: {}; stderr:\n{}", + expected_profile, + event, + self.helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + assert_eq!(switched["profile"].as_u64(), Some(expected_profile)); + + wait_matching_service_event( + &mut self.event_rx, + Duration::from_secs(5), + "Python profile switch reaches Rust", + |event| { + matches!( + event, + TelephonyServiceEvent::Snapshot(snapshot) + if snapshot.active_call.as_ref().is_some_and(|call| { + call.status == SignallingStatus::Established + && call.profile == Some(profile) + }) + ) + }, + ) + .await; + self.profile = profile; + } + + fn wait_python_preferred_profile_signal(&self, profile: Profile) { + let expected_signal = python_preferred_profile_signal(profile); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!( + !remaining.is_zero(), + "timed out waiting for Python preferred-profile signal {}; helper stderr:\n{}", + expected_signal, + self.helper.stderr_log() + ); + if let Some(event) = self + .helper + .try_wait_event("SIGNALS", remaining.min(Duration::from_millis(500))) + { + let saw_expected = event["signals"].as_array().is_some_and(|signals| { + signals + .iter() + .any(|signal| signal.as_u64() == Some(expected_signal)) + }); + if saw_expected { + return; + } + } + } + } + + async fn wait_python_active_profile(&mut self, profile: Profile) { + let expected_profile = u64::from(profile.wire_value()); + let deadline = Instant::now() + Duration::from_secs(5); + let mut attempt = 0_u64; + loop { + attempt += 1; + self.helper.send(json!({ + "cmd": "snapshot", + "id": format!("profile-switch-{}-{attempt}", profile.abbreviation()), + })); + let event = self + .helper + .wait_event("SNAPSHOT", Duration::from_millis(500)); + let active_call_profile = event["active_call"]["profile"].as_u64(); + let active_profile = event["active_profile"].as_u64(); + if active_profile == Some(expected_profile) + && active_call_profile == Some(expected_profile) + { + return; + } + + assert!( + Instant::now() < deadline, + "timed out waiting for Python active profile {}; last snapshot: {}; helper stderr:\n{}", + expected_profile, + event, + self.helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + async fn shutdown(self) { + let Self { + actor_tx, + rust_destination_hash, + accepted_read_task, + server_read_task, + service_task, + control_tx, + mut event_rx, + .. + } = self; + + control_tx + .send(TelephonyControl::Shutdown) + .await + .expect("send Rust Telephone service shutdown"); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(2), + "service stopped", + |event| matches!(event, TelephonyServiceEvent::Stopped), + ) + .await; + service_task.await.expect("join Rust Telephone service"); + actor_tx + .send(TransportMessage::DeregisterDestination { + hash: rust_destination_hash, + }) + .await + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server_read_task.abort(); + } +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn python_telephone_announce_reaches_rust_transport() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Python LXST Telephone announce interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + let mut announce_rx = register_telephony_announce_handler(&actor_tx).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + let destination_hash = hash_from_ready(&ready, "destination_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + + helper.send(json!({"cmd": "announce", "id": "announce-1"})); + let announced = helper.wait_event("ANNOUNCED", Duration::from_secs(10)); + assert_eq!(announced["destination_hash"], hex::encode(destination_hash)); + + let announce = tokio::time::timeout(Duration::from_secs(15), announce_rx.recv()) + .await + .unwrap_or_else(|_| panic!("Rust transport never received Python announce")) + .expect("announce handler channel closed"); + assert_eq!(announce.destination_hash, destination_hash); + assert_eq!(announce.name_hash, name_hash(TELEPHONY_DESTINATION_NAME)); + assert!( + announce.public_key.is_some(), + "Python Telephone announce did not include public key" + ); + assert!( + announce.hops <= 1, + "direct Python TCP announce should not arrive through a routed path" + ); + + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + let local_identity = Identity::new(); + let endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let remote_identity_hash = hash_from_ready(&ready, "identity_hash"); + let peer = endpoint + .discover_remote_telephony_peer(remote_identity_hash, Duration::from_secs(2)) + .await + .expect("discover Python Telephone peer from live announce cache"); + assert_eq!(peer.identity_hash, remote_identity_hash); + assert_eq!(peer.destination_hash, destination_hash); + assert_eq!( + peer.public_key, + announce.public_key.expect("announce public key") + ); + endpoint + .await_path_to_identity(remote_identity_hash, Duration::from_secs(2)) + .await + .expect("live Python Telephone path is available after announce"); + endpoint + .deregister_destination() + .expect("deregister Rust Telephone endpoint"); + + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn rust_to_rust_call_establishes_through_python_transport_hub() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Rust/Rust Python transport-hub interop"); + return; + } + + let port = free_tcp_port(); + let hub = PythonTelephoneHost::spawn_transport_hub(port); + let ready = hub.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + + let mut caller = RustHubNode::spawn("rust-hub-caller", port, 11_001).await; + let mut caller_announces = register_telephony_announce_handler(&caller.actor_tx).await; + let mut callee = RustHubNode::spawn("rust-hub-callee", port, 12_001).await; + + let announce = wait_for_announce( + &mut caller_announces, + callee.destination_hash, + Duration::from_secs(20), + "callee through Python hub", + ) + .await; + assert_eq!(announce.name_hash, name_hash(TELEPHONY_DESTINATION_NAME)); + assert!( + announce.public_key.is_some(), + "Rust callee announce through Python hub did not include a public key" + ); + assert!( + announce.hops >= 1, + "callee announce should arrive through at least one transport hop" + ); + + caller + .control_tx + .send(TelephonyControl::Call { + remote_identity: callee.identity.hash, + profile: Some(Profile::QualityMedium), + discovery_timeout: Duration::from_secs(10), + }) + .await + .expect("send caller Call control"); + + wait_matching_service_event( + &mut caller.event_rx, + Duration::from_secs(10), + "outgoing call started through Python hub", + |event| matches!(event, TelephonyServiceEvent::OutgoingCallStarted { .. }), + ) + .await; + + let incoming = wait_matching_service_event( + &mut callee.event_rx, + Duration::from_secs(15), + "incoming call through Python hub", + |event| matches!(event, TelephonyServiceEvent::IncomingCall { .. }), + ) + .await; + match incoming { + TelephonyServiceEvent::IncomingCall { + remote_identity, .. + } => assert_eq!(remote_identity, caller.identity.hash), + other => panic!("expected IncomingCall, got {other:?}"), + } + + callee + .control_tx + .send(TelephonyControl::Answer) + .await + .expect("send callee Answer control"); + + wait_matching_service_event( + &mut caller.event_rx, + Duration::from_secs(15), + "caller established through Python hub", + |event| { + matches!( + event, + TelephonyServiceEvent::Snapshot(snapshot) + if snapshot.active_call.as_ref().is_some_and(|call| { + call.status == SignallingStatus::Established + && call.role == CallRole::Outgoing + && call.remote_identity == callee.identity.hash + }) + ) + }, + ) + .await; + + wait_matching_service_event( + &mut callee.event_rx, + Duration::from_secs(15), + "callee established through Python hub", + |event| { + matches!( + event, + TelephonyServiceEvent::Snapshot(snapshot) + if snapshot.active_call.as_ref().is_some_and(|call| { + call.status == SignallingStatus::Established + && call.role == CallRole::Incoming + && call.remote_identity == caller.identity.hash + }) + ) + }, + ) + .await; + + caller.shutdown().await; + callee.shutdown().await; + drop(hub); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn rust_telephony_startup_retry_announces_after_late_tcp_hub_connection() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Rust/Rust delayed TCP-hub interop"); + return; + } + + let port = free_tcp_port(); + let hub = PythonTelephoneHost::spawn_transport_hub(port); + let ready = hub.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + + let caller = RustHubNode::spawn("rust-hub-caller-late", port, 13_001).await; + let mut caller_announces = register_telephony_announce_handler(&caller.actor_tx).await; + let mut callee = RustHubNode::spawn_before_tcp().await; + + tokio::time::sleep(Duration::from_millis(150)).await; + callee + .attach_tcp("rust-hub-callee-late", port, 14_001) + .await; + + let announce = wait_for_announce( + &mut caller_announces, + callee.destination_hash, + Duration::from_secs(5), + "callee startup retry after late TCP hub connection", + ) + .await; + assert_eq!(announce.name_hash, name_hash(TELEPHONY_DESTINATION_NAME)); + assert!( + announce.public_key.is_some(), + "Rust callee startup retry announce through Python hub did not include a public key" + ); + assert!( + announce.hops >= 1, + "callee startup retry announce should arrive through at least one transport hop" + ); + + caller.shutdown().await; + callee.shutdown().await; + drop(hub); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_outgoing_call_reaches_python_ringing_without_audio() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Python LXST Telephone call interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + let mut announce_rx = register_telephony_announce_handler(&actor_tx).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let remote_destination_hash = hash_from_ready(&ready, "destination_hash"); + let remote_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + helper.send(json!({"cmd": "announce", "id": "announce-1"})); + let announced = helper.wait_event("ANNOUNCED", Duration::from_secs(10)); + assert_eq!( + announced["destination_hash"], + hex::encode(remote_destination_hash) + ); + let announce = tokio::time::timeout(Duration::from_secs(15), announce_rx.recv()) + .await + .unwrap_or_else(|_| panic!("Rust transport never received Python announce")) + .expect("announce handler channel closed"); + assert_eq!(announce.destination_hash, remote_destination_hash); + + let local_identity = Identity::new(); + let mut endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let mut core = TelephonyRuntimeCore::new(); + let link_id = endpoint + .begin_outgoing_link( + &mut core, + remote_identity_hash, + None, + Duration::from_secs(5), + ) + .await + .expect("start outgoing Rust -> Python Telephone link"); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust Telephone endpoint"); + + let snapshot = core.snapshot(); + if snapshot + .active_call + .as_ref() + .is_some_and(|call| call.status == SignallingStatus::Ringing) + { + let call = snapshot.active_call.expect("active outgoing call"); + assert_eq!(call.link_id, link_id); + assert_eq!(call.remote_identity, remote_identity_hash); + assert_eq!(call.role, CallRole::Outgoing); + break; + } + + assert!( + tokio::time::Instant::now() < deadline, + "Rust outgoing call did not reach Python RINGING; helper stderr:\n{}", + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let ringing = helper.wait_event("RINGING", Duration::from_secs(5)); + let local_identity_hash = hex::encode(local_identity.hash); + assert_eq!( + ringing["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + + endpoint + .deregister_destination() + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_outgoing_call_establishes_when_python_answers_without_audio() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Rust caller/Python answer interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + let mut announce_rx = register_telephony_announce_handler(&actor_tx).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let remote_destination_hash = hash_from_ready(&ready, "destination_hash"); + let remote_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + helper.send(json!({"cmd": "announce", "id": "announce-1"})); + let announced = helper.wait_event("ANNOUNCED", Duration::from_secs(10)); + assert_eq!( + announced["destination_hash"], + hex::encode(remote_destination_hash) + ); + let announce = tokio::time::timeout(Duration::from_secs(15), announce_rx.recv()) + .await + .unwrap_or_else(|_| panic!("Rust transport never received Python announce")) + .expect("announce handler channel closed"); + assert_eq!(announce.destination_hash, remote_destination_hash); + + let local_identity = Identity::new(); + let mut endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let mut core = TelephonyRuntimeCore::new(); + let link_id = endpoint + .begin_outgoing_link( + &mut core, + remote_identity_hash, + None, + Duration::from_secs(5), + ) + .await + .expect("start outgoing Rust -> Python Telephone link"); + + let ringing_deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust Telephone endpoint"); + let snapshot = core.snapshot(); + if snapshot + .active_call + .as_ref() + .is_some_and(|call| call.status == SignallingStatus::Ringing) + { + break; + } + assert!( + tokio::time::Instant::now() < ringing_deadline, + "Rust outgoing call did not reach Python RINGING; helper stderr:\n{}", + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let ringing = helper.wait_event("RINGING", Duration::from_secs(5)); + let local_identity_hash = hex::encode(local_identity.hash); + assert_eq!( + ringing["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + + helper.send(json!({"cmd": "answer", "id": "answer-1"})); + let answered = helper.wait_event("ANSWERED", Duration::from_secs(5)); + assert_eq!(answered["accepted"], true); + let established = helper.wait_event("ESTABLISHED", Duration::from_secs(5)); + assert_eq!( + established["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + + let established_deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust Telephone endpoint"); + let snapshot = core.snapshot(); + if snapshot + .active_call + .as_ref() + .is_some_and(|call| call.status == SignallingStatus::Established) + { + let call = snapshot.active_call.expect("active outgoing call"); + assert_eq!(call.link_id, link_id); + assert_eq!(call.remote_identity, remote_identity_hash); + assert_eq!(call.role, CallRole::Outgoing); + assert_eq!(call.profile, Some(Profile::DEFAULT)); + break; + } + assert!( + tokio::time::Instant::now() < established_deadline, + "Rust outgoing call did not reach ESTABLISHED after Python answer; helper stderr:\n{}", + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + endpoint + .deregister_destination() + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn python_outgoing_call_reaches_rust_incoming_ringing_without_audio() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Python LXST Telephone caller interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let python_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + let local_identity = Identity::new(); + let mut endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let mut core = TelephonyRuntimeCore::new(); + let rust_destination_hash = send_rust_telephony_announce(&actor_tx, &local_identity).await; + assert_eq!(rust_destination_hash, endpoint.destination_hash); + + tokio::time::sleep(Duration::from_millis(500)).await; + helper.send(json!({ + "cmd": "call", + "id": "call-rust-1", + "target_destination_hash": hex::encode(rust_destination_hash), + })); + let requested = helper.wait_event("CALL_REQUESTED", Duration::from_secs(10)); + assert_eq!( + requested["target_destination_hash"], + hex::encode(rust_destination_hash) + ); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust Telephone endpoint"); + + let snapshot = core.snapshot(); + if snapshot + .active_call + .as_ref() + .is_some_and(|call| call.status == SignallingStatus::Ringing) + { + let call = snapshot.active_call.expect("active incoming call"); + assert_eq!(call.remote_identity, python_identity_hash); + assert_eq!(call.role, CallRole::Incoming); + break; + } + + assert!( + tokio::time::Instant::now() < deadline, + "Python outgoing call did not reach Rust incoming RINGING; helper stderr:\n{}", + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + endpoint + .deregister_destination() + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn python_outgoing_call_establishes_when_rust_answers_without_audio() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Python caller/Rust answer interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let python_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + let local_identity = Identity::new(); + let mut endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let mut core = TelephonyRuntimeCore::new(); + let rust_destination_hash = send_rust_telephony_announce(&actor_tx, &local_identity).await; + assert_eq!(rust_destination_hash, endpoint.destination_hash); + + tokio::time::sleep(Duration::from_millis(500)).await; + helper.send(json!({ + "cmd": "call", + "id": "call-rust-1", + "target_destination_hash": hex::encode(rust_destination_hash), + })); + let requested = helper.wait_event("CALL_REQUESTED", Duration::from_secs(10)); + assert_eq!( + requested["target_destination_hash"], + hex::encode(rust_destination_hash) + ); + + let ringing_deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust Telephone endpoint"); + + let snapshot = core.snapshot(); + if snapshot + .active_call + .as_ref() + .is_some_and(|call| call.status == SignallingStatus::Ringing) + { + let call = snapshot.active_call.expect("active incoming call"); + assert_eq!(call.remote_identity, python_identity_hash); + assert_eq!(call.role, CallRole::Incoming); + break; + } + + assert!( + tokio::time::Instant::now() < ringing_deadline, + "Python outgoing call did not reach Rust incoming RINGING; helper stderr:\n{}", + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let commands = core + .answer_active() + .expect("answer active Rust incoming call"); + endpoint + .execute_commands(&commands) + .expect("send Rust answer signalling"); + let established = helper.wait_event("ESTABLISHED", Duration::from_secs(5)); + let local_identity_hash = hex::encode(local_identity.hash); + assert_eq!( + established["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust endpoint after Python establishment response"); + let snapshot = core.snapshot(); + let call = snapshot + .active_call + .expect("active incoming established call"); + assert_eq!(call.remote_identity, python_identity_hash); + assert_eq!(call.role, CallRole::Incoming); + assert_eq!(call.status, SignallingStatus::Established); + assert_eq!(call.profile, Some(Profile::DEFAULT)); + assert!(call.answered); + + endpoint + .deregister_destination() + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_hangup_after_python_answer_ends_python_call_without_audio() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Rust hangup/Python ended interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + let mut announce_rx = register_telephony_announce_handler(&actor_tx).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let remote_destination_hash = hash_from_ready(&ready, "destination_hash"); + let remote_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + helper.send(json!({"cmd": "announce", "id": "announce-1"})); + let announced = helper.wait_event("ANNOUNCED", Duration::from_secs(10)); + assert_eq!( + announced["destination_hash"], + hex::encode(remote_destination_hash) + ); + let announce = tokio::time::timeout(Duration::from_secs(15), announce_rx.recv()) + .await + .unwrap_or_else(|_| panic!("Rust transport never received Python announce")) + .expect("announce handler channel closed"); + assert_eq!(announce.destination_hash, remote_destination_hash); + + let local_identity = Identity::new(); + let mut endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let mut core = TelephonyRuntimeCore::new(); + endpoint + .begin_outgoing_link( + &mut core, + remote_identity_hash, + None, + Duration::from_secs(5), + ) + .await + .expect("start outgoing Rust -> Python Telephone link"); + + let ringing_deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust Telephone endpoint"); + let snapshot = core.snapshot(); + if snapshot + .active_call + .as_ref() + .is_some_and(|call| call.status == SignallingStatus::Ringing) + { + break; + } + assert!( + tokio::time::Instant::now() < ringing_deadline, + "Rust outgoing call did not reach Python RINGING; helper stderr:\n{}", + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + helper.wait_event("RINGING", Duration::from_secs(5)); + helper.send(json!({"cmd": "answer", "id": "answer-1"})); + let answered = helper.wait_event("ANSWERED", Duration::from_secs(5)); + assert_eq!(answered["accepted"], true); + helper.wait_event("ESTABLISHED", Duration::from_secs(5)); + + let established_deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust Telephone endpoint"); + if core + .snapshot() + .active_call + .as_ref() + .is_some_and(|call| call.status == SignallingStatus::Established) + { + break; + } + assert!( + tokio::time::Instant::now() < established_deadline, + "Rust outgoing call did not reach ESTABLISHED after Python answer; helper stderr:\n{}", + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let commands = core + .hangup_active(false) + .expect("hang up active Rust outgoing call"); + endpoint + .execute_commands(&commands) + .expect("send Rust link close"); + assert!(core.snapshot().active_call.is_none()); + let ended = helper.wait_event("ENDED", Duration::from_secs(5)); + let local_identity_hash = hex::encode(local_identity.hash); + assert_eq!( + ended["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + + endpoint + .deregister_destination() + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn python_hangup_after_rust_answer_ends_rust_call_without_audio() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Python hangup/Rust ended interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let python_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + let local_identity = Identity::new(); + let mut endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let mut core = TelephonyRuntimeCore::new(); + let rust_destination_hash = send_rust_telephony_announce(&actor_tx, &local_identity).await; + assert_eq!(rust_destination_hash, endpoint.destination_hash); + + tokio::time::sleep(Duration::from_millis(500)).await; + helper.send(json!({ + "cmd": "call", + "id": "call-rust-1", + "target_destination_hash": hex::encode(rust_destination_hash), + })); + let requested = helper.wait_event("CALL_REQUESTED", Duration::from_secs(10)); + assert_eq!( + requested["target_destination_hash"], + hex::encode(rust_destination_hash) + ); + + let ringing_deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust Telephone endpoint"); + + let snapshot = core.snapshot(); + if snapshot + .active_call + .as_ref() + .is_some_and(|call| call.status == SignallingStatus::Ringing) + { + let call = snapshot.active_call.expect("active incoming call"); + assert_eq!(call.remote_identity, python_identity_hash); + assert_eq!(call.role, CallRole::Incoming); + break; + } + + assert!( + tokio::time::Instant::now() < ringing_deadline, + "Python outgoing call did not reach Rust incoming RINGING; helper stderr:\n{}", + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let commands = core + .answer_active() + .expect("answer active Rust incoming call"); + endpoint + .execute_commands(&commands) + .expect("send Rust answer signalling"); + helper.wait_event("ESTABLISHED", Duration::from_secs(5)); + + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust endpoint after Python establishment response"); + assert_eq!( + core.snapshot() + .active_call + .as_ref() + .expect("active incoming established call") + .status, + SignallingStatus::Established + ); + + helper.send(json!({"cmd": "hangup", "id": "hangup-1"})); + helper.wait_event("HUNG_UP", Duration::from_secs(5)); + + let ended_deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust Telephone endpoint after Python hangup"); + if core.snapshot().active_call.is_none() { + break; + } + assert!( + tokio::time::Instant::now() < ended_deadline, + "Rust incoming call did not clear after Python hangup; helper stderr:\n{}", + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + endpoint + .deregister_destination() + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_outgoing_call_receives_python_busy_without_audio() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Python busy/Rust caller interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + let mut announce_rx = register_telephony_announce_handler(&actor_tx).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let remote_destination_hash = hash_from_ready(&ready, "destination_hash"); + let remote_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + helper.send(json!({"cmd": "set_busy", "id": "busy-1", "busy": true})); + let busy_set = helper.wait_event("BUSY_SET", Duration::from_secs(5)); + assert_eq!(busy_set["busy"], true); + + helper.send(json!({"cmd": "announce", "id": "announce-1"})); + let announced = helper.wait_event("ANNOUNCED", Duration::from_secs(10)); + assert_eq!( + announced["destination_hash"], + hex::encode(remote_destination_hash) + ); + let announce = tokio::time::timeout(Duration::from_secs(15), announce_rx.recv()) + .await + .unwrap_or_else(|_| panic!("Rust transport never received Python announce")) + .expect("announce handler channel closed"); + assert_eq!(announce.destination_hash, remote_destination_hash); + + let local_identity = Identity::new(); + let mut endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let mut core = TelephonyRuntimeCore::new(); + endpoint + .begin_outgoing_link( + &mut core, + remote_identity_hash, + None, + Duration::from_secs(5), + ) + .await + .expect("start outgoing Rust -> busy Python Telephone link"); + + drive_until_call_cleared( + &mut endpoint, + &mut core, + &helper, + Some(SignallingStatus::Busy), + Duration::from_secs(10), + "Rust caller did not terminate with BUSY from Python callee", + ) + .await; + + endpoint + .deregister_destination() + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn python_outgoing_call_receives_rust_busy_without_audio() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Rust busy/Python caller interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + let local_identity = Identity::new(); + let mut endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let mut core = TelephonyRuntimeCore::new(); + core.set_external_busy(true); + let rust_destination_hash = send_rust_telephony_announce(&actor_tx, &local_identity).await; + assert_eq!(rust_destination_hash, endpoint.destination_hash); + + tokio::time::sleep(Duration::from_millis(500)).await; + helper.send(json!({ + "cmd": "call", + "id": "call-busy-rust-1", + "target_destination_hash": hex::encode(rust_destination_hash), + })); + let requested = helper.wait_event("CALL_REQUESTED", Duration::from_secs(10)); + assert_eq!( + requested["target_destination_hash"], + hex::encode(rust_destination_hash) + ); + + let busy_signal = u64::from(SignallingStatus::Busy.wire_value()); + let busy_deadline = tokio::time::Instant::now() + Duration::from_secs(15); + let mut busy_callback = None; + let mut saw_busy_signal = false; + loop { + endpoint + .try_drive_ready(&mut core) + .expect("drive Rust Telephone endpoint while waiting for Python busy"); + if let Some(busy) = helper.try_wait_event("BUSY", Duration::from_millis(10)) { + busy_callback = Some(busy); + saw_busy_signal = true; + break; + } + if let Some(signals) = helper.try_wait_event("SIGNALS", Duration::from_millis(10)) { + saw_busy_signal |= signals["signals"].as_array().is_some_and(|signals| { + signals + .iter() + .any(|signal| signal.as_u64() == Some(busy_signal)) + }); + if saw_busy_signal { + break; + } + } + + assert!( + tokio::time::Instant::now() < busy_deadline, + "Python caller did not receive BUSY from externally busy Rust callee; helper stderr:\n{}", + helper.stderr_log() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + if let Some(busy) = busy_callback { + let local_identity_hash = hex::encode(local_identity.hash); + assert_eq!( + busy["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + } + assert!(saw_busy_signal); + assert!(core.snapshot().active_call.is_none()); + assert!(core.snapshot().external_busy); + + endpoint + .deregister_destination() + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_outgoing_call_receives_python_reject_without_audio() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Python reject/Rust caller interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + let mut announce_rx = register_telephony_announce_handler(&actor_tx).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let remote_destination_hash = hash_from_ready(&ready, "destination_hash"); + let remote_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + helper.send(json!({"cmd": "announce", "id": "announce-1"})); + let announced = helper.wait_event("ANNOUNCED", Duration::from_secs(10)); + assert_eq!( + announced["destination_hash"], + hex::encode(remote_destination_hash) + ); + let announce = tokio::time::timeout(Duration::from_secs(15), announce_rx.recv()) + .await + .unwrap_or_else(|_| panic!("Rust transport never received Python announce")) + .expect("announce handler channel closed"); + assert_eq!(announce.destination_hash, remote_destination_hash); + + let local_identity = Identity::new(); + let mut endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let mut core = TelephonyRuntimeCore::new(); + endpoint + .begin_outgoing_link( + &mut core, + remote_identity_hash, + None, + Duration::from_secs(5), + ) + .await + .expect("start outgoing Rust -> Python Telephone link"); + + let call = drive_until_active_status( + &mut endpoint, + &mut core, + &helper, + SignallingStatus::Ringing, + Duration::from_secs(10), + "Rust outgoing call did not reach Python RINGING before reject test", + ) + .await; + assert_eq!(call.remote_identity, remote_identity_hash); + assert_eq!(call.role, CallRole::Outgoing); + + let ringing = helper.wait_event("RINGING", Duration::from_secs(5)); + let local_identity_hash = hex::encode(local_identity.hash); + assert_eq!( + ringing["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + helper.send(json!({"cmd": "hangup", "id": "reject-rust-1"})); + helper.wait_event("HUNG_UP", Duration::from_secs(5)); + + drive_until_call_cleared( + &mut endpoint, + &mut core, + &helper, + Some(SignallingStatus::Rejected), + Duration::from_secs(10), + "Rust caller did not terminate with REJECTED from Python callee", + ) + .await; + + endpoint + .deregister_destination() + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn python_outgoing_call_receives_rust_reject_without_audio() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Rust reject/Python caller interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let python_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + let local_identity = Identity::new(); + let mut endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let mut core = TelephonyRuntimeCore::new(); + let rust_destination_hash = send_rust_telephony_announce(&actor_tx, &local_identity).await; + assert_eq!(rust_destination_hash, endpoint.destination_hash); + + tokio::time::sleep(Duration::from_millis(500)).await; + helper.send(json!({ + "cmd": "call", + "id": "call-reject-rust-1", + "target_destination_hash": hex::encode(rust_destination_hash), + })); + let requested = helper.wait_event("CALL_REQUESTED", Duration::from_secs(10)); + assert_eq!( + requested["target_destination_hash"], + hex::encode(rust_destination_hash) + ); + + let call = drive_until_active_status( + &mut endpoint, + &mut core, + &helper, + SignallingStatus::Ringing, + Duration::from_secs(10), + "Python outgoing call did not reach Rust incoming RINGING before reject test", + ) + .await; + assert_eq!(call.remote_identity, python_identity_hash); + assert_eq!(call.role, CallRole::Incoming); + + let commands = core + .hangup_active(false) + .expect("reject active Rust incoming call"); + endpoint + .execute_commands(&commands) + .expect("send Rust reject signalling"); + assert!(core.snapshot().active_call.is_none()); + + let local_identity_hash = hex::encode(local_identity.hash); + if let Some(rejected) = helper.try_wait_event("REJECTED", Duration::from_secs(2)) { + assert_eq!( + rejected["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + } else { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let now = Instant::now(); + assert!( + now < deadline, + "Python helper did not observe rejected signalling before call ended" + ); + let signals = + helper.wait_event("SIGNALS", (deadline - now).min(Duration::from_secs(1))); + let saw_rejected = signals["signals"] + .as_array() + .is_some_and(|signals| signals.iter().any(|signal| signal.as_u64() == Some(1))); + if saw_rejected { + break; + } + } + let ended = helper.wait_event("ENDED", Duration::from_secs(5)); + assert_eq!( + ended["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + } + + endpoint + .deregister_destination() + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_service_outgoing_timeout_ends_python_ringing_call_without_audio() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Rust outgoing timeout/Python callee interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + let mut announce_rx = register_telephony_announce_handler(&actor_tx).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let remote_destination_hash = hash_from_ready(&ready, "destination_hash"); + let remote_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + helper.send(json!({"cmd": "announce", "id": "announce-1"})); + let announced = helper.wait_event("ANNOUNCED", Duration::from_secs(10)); + assert_eq!( + announced["destination_hash"], + hex::encode(remote_destination_hash) + ); + let announce = tokio::time::timeout(Duration::from_secs(15), announce_rx.recv()) + .await + .unwrap_or_else(|_| panic!("Rust transport never received Python announce")) + .expect("announce handler channel closed"); + assert_eq!(announce.destination_hash, remote_destination_hash); + + let local_identity = Identity::new(); + let endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let rust_destination_hash = endpoint.destination_hash; + let (control_tx, control_rx) = mpsc::channel(16); + let (event_tx, mut event_rx) = mpsc::channel(64); + let service = TelephonyService::with_config( + endpoint, + TelephonyRuntimeCore::new(), + control_rx, + event_tx, + TelephonyServiceConfig { + poll_interval: Duration::from_millis(20), + incoming_ring_timeout: Some(Duration::from_secs(10)), + outgoing_call_timeout: Some(Duration::from_millis(900)), + media_frames_per_tick: 4, + ..TelephonyServiceConfig::default() + }, + ); + let service_task = tokio::spawn(service.run()); + + control_tx + .send(TelephonyControl::Call { + remote_identity: remote_identity_hash, + profile: None, + discovery_timeout: Duration::from_secs(5), + }) + .await + .expect("send Rust Telephone service call control"); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "outgoing call started", + |event| { + matches!( + event, + TelephonyServiceEvent::OutgoingCallStarted { + remote_identity, + .. + } if *remote_identity == remote_identity_hash + ) + }, + ) + .await; + + let ringing = helper.wait_event("RINGING", Duration::from_secs(5)); + let local_identity_hash = hex::encode(local_identity.hash); + assert_eq!( + ringing["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + + let terminated = wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "outgoing timeout termination", + |event| { + matches!( + event, + TelephonyServiceEvent::CallTerminated { reason: None, .. } + ) + }, + ) + .await; + assert!(matches!( + terminated, + TelephonyServiceEvent::CallTerminated { reason: None, .. } + )); + + let ended = helper.wait_event("ENDED", Duration::from_secs(5)); + assert_eq!( + ended["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + + control_tx + .send(TelephonyControl::Shutdown) + .await + .expect("send Rust Telephone service shutdown"); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(2), + "service stopped", + |event| matches!(event, TelephonyServiceEvent::Stopped), + ) + .await; + service_task.await.expect("join Rust Telephone service"); + actor_tx + .send(TransportMessage::DeregisterDestination { + hash: rust_destination_hash, + }) + .await + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_service_incoming_timeout_ends_python_outgoing_call_without_reject() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Rust incoming timeout/Python caller interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let python_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + let local_identity = Identity::new(); + let endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let rust_destination_hash = endpoint.destination_hash; + let (control_tx, control_rx) = mpsc::channel(16); + let (event_tx, mut event_rx) = mpsc::channel(64); + let service = TelephonyService::with_config( + endpoint, + TelephonyRuntimeCore::new(), + control_rx, + event_tx, + TelephonyServiceConfig { + poll_interval: Duration::from_millis(20), + incoming_ring_timeout: Some(Duration::from_millis(900)), + outgoing_call_timeout: Some(Duration::from_secs(10)), + media_frames_per_tick: 4, + ..TelephonyServiceConfig::default() + }, + ); + let service_task = tokio::spawn(service.run()); + let announced_hash = send_rust_telephony_announce(&actor_tx, &local_identity).await; + assert_eq!(announced_hash, rust_destination_hash); + + tokio::time::sleep(Duration::from_millis(500)).await; + helper.send(json!({ + "cmd": "call", + "id": "call-timeout-rust-1", + "target_destination_hash": hex::encode(rust_destination_hash), + })); + let requested = helper.wait_event("CALL_REQUESTED", Duration::from_secs(10)); + assert_eq!( + requested["target_destination_hash"], + hex::encode(rust_destination_hash) + ); + + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "incoming call", + |event| { + matches!( + event, + TelephonyServiceEvent::IncomingCall { + remote_identity, + .. + } if *remote_identity == python_identity_hash + ) + }, + ) + .await; + + let terminated = wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "incoming timeout termination", + |event| { + matches!( + event, + TelephonyServiceEvent::CallTerminated { reason: None, .. } + ) + }, + ) + .await; + assert!(matches!( + terminated, + TelephonyServiceEvent::CallTerminated { reason: None, .. } + )); + + let ended = helper.wait_event("ENDED", Duration::from_secs(5)); + let local_identity_hash = hex::encode(local_identity.hash); + assert_eq!( + ended["identity_hash"].as_str(), + Some(local_identity_hash.as_str()) + ); + assert!( + helper + .try_wait_event("REJECTED", Duration::from_millis(100)) + .is_none(), + "incoming ring timeout must close the link without rejected signalling" + ); + + control_tx + .send(TelephonyControl::Shutdown) + .await + .expect("send Rust Telephone service shutdown"); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(2), + "service stopped", + |event| matches!(event, TelephonyServiceEvent::Stopped), + ) + .await; + service_task.await.expect("join Rust Telephone service"); + actor_tx + .send(TransportMessage::DeregisterDestination { + hash: rust_destination_hash, + }) + .await + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_service_sends_raw_media_to_python_established_call() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Rust Raw media/Python receiver interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + let mut announce_rx = register_telephony_announce_handler(&actor_tx).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let remote_destination_hash = hash_from_ready(&ready, "destination_hash"); + let remote_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + helper.send(json!({"cmd": "announce", "id": "announce-1"})); + let announced = helper.wait_event("ANNOUNCED", Duration::from_secs(10)); + assert_eq!( + announced["destination_hash"], + hex::encode(remote_destination_hash) + ); + let announce = tokio::time::timeout(Duration::from_secs(15), announce_rx.recv()) + .await + .unwrap_or_else(|_| panic!("Rust transport never received Python announce")) + .expect("announce handler channel closed"); + assert_eq!(announce.destination_hash, remote_destination_hash); + + let local_identity = Identity::new(); + let endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let rust_destination_hash = endpoint.destination_hash; + let (control_tx, control_rx) = mpsc::channel(16); + let (event_tx, mut event_rx) = mpsc::channel(64); + let service = TelephonyService::with_config( + endpoint, + TelephonyRuntimeCore::new(), + control_rx, + event_tx, + TelephonyServiceConfig { + poll_interval: Duration::from_millis(20), + incoming_ring_timeout: Some(Duration::from_secs(10)), + outgoing_call_timeout: Some(Duration::from_secs(10)), + media_frames_per_tick: 4, + ..TelephonyServiceConfig::default() + }, + ); + let service_task = tokio::spawn(service.run()); + + control_tx + .send(TelephonyControl::Call { + remote_identity: remote_identity_hash, + profile: None, + discovery_timeout: Duration::from_secs(5), + }) + .await + .expect("send Rust Telephone service call control"); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "outgoing call started", + |event| matches!(event, TelephonyServiceEvent::OutgoingCallStarted { .. }), + ) + .await; + + helper.wait_event("RINGING", Duration::from_secs(5)); + helper.send(json!({"cmd": "answer", "id": "answer-media-1"})); + let answered = helper.wait_event("ANSWERED", Duration::from_secs(5)); + assert_eq!(answered["accepted"], true); + helper.wait_event("ESTABLISHED", Duration::from_secs(5)); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "established snapshot", + |event| { + matches!( + event, + TelephonyServiceEvent::Snapshot(snapshot) + if snapshot.active_call.as_ref().is_some_and(|call| call.status == SignallingStatus::Established) + ) + }, + ) + .await; + + control_tx + .send(TelephonyControl::SendRawFrames { + bit_depth: RawBitDepth::Float32, + frames: vec![ + RawAudioFrame::new(1, vec![0.0, 0.25, -0.5]).unwrap(), + RawAudioFrame::new(1, vec![1.0, -1.0]).unwrap(), + ], + }) + .await + .expect("send Raw media frames through Rust Telephone service"); + let sent = wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "media sent", + |event| { + matches!( + event, + TelephonyServiceEvent::MediaSent { + frames: 2, + packets: 2, + .. + } + ) + }, + ) + .await; + assert!(matches!( + sent, + TelephonyServiceEvent::MediaSent { + frames: 2, + packets: 2, + .. + } + )); + + let first = helper.wait_event("MEDIA_FRAME", Duration::from_secs(5)); + assert_eq!(first["shape"], json!([3, 1])); + assert_eq!(first["samples"], json!([0.0, 0.25, -0.5])); + let second = helper.wait_event("MEDIA_FRAME", Duration::from_secs(5)); + assert_eq!(second["shape"], json!([2, 1])); + assert_eq!(second["samples"], json!([1.0, -1.0])); + + control_tx + .send(TelephonyControl::Shutdown) + .await + .expect("send Rust Telephone service shutdown"); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(2), + "service stopped", + |event| matches!(event, TelephonyServiceEvent::Stopped), + ) + .await; + service_task.await.expect("join Rust Telephone service"); + actor_tx + .send(TransportMessage::DeregisterDestination { + hash: rust_destination_hash, + }) + .await + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_service_sends_opus_voice_profile_matrix_to_python_established_calls() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Rust Opus media/Python receiver interop"); + return; + } + + for profile in OPUS_VOICE_PROFILES { + let Some(mut session) = RustCallerSession::establish(profile).await else { + return; + }; + let received = session.send_rust_opus_to_python().await; + let expected_frames = python_headless_output_frames(profile); + let expected_total_samples = expected_frames * PYTHON_HEADLESS_AUDIO_CHANNELS; + + assert_eq!(received["decoded"], true, "{}", profile.name()); + assert_eq!( + received["shape"], + json!([expected_frames, PYTHON_HEADLESS_AUDIO_CHANNELS]), + "{}", + profile.name() + ); + assert_eq!( + received["total_samples"], + expected_total_samples, + "{}", + profile.name() + ); + session.shutdown().await; + } +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_profile_switch_reaches_python_and_media_uses_new_profile() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Rust-to-Python profile-switch interop"); + return; + } + + let Some(mut session) = RustCallerSession::establish(Profile::QualityMedium).await else { + return; + }; + session.switch_rust_profile(Profile::QualityHigh).await; + + let received = session.send_rust_opus_to_python().await; + let expected_frames = python_headless_output_frames(Profile::QualityHigh); + assert_eq!(received["decoded"], true); + assert_eq!( + received["shape"], + json!([expected_frames, PYTHON_HEADLESS_AUDIO_CHANNELS]) + ); + + session.shutdown().await; +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn python_profile_switch_reaches_rust_and_media_uses_new_profile() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Python-to-Rust profile-switch interop"); + return; + } + + let Some(mut session) = RustCallerSession::establish(Profile::QualityHigh).await else { + return; + }; + session.switch_python_profile(Profile::QualityMax).await; + session.send_python_opus_to_rust().await; + session.shutdown().await; +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_service_receives_python_raw_media_on_established_call() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Python Raw media/Rust receiver interop"); + return; + } + + let port = free_tcp_port(); + let (actor_tx, server, mut handle_rx) = spawn_rust_actor_and_tcp(port).await; + let mut announce_rx = register_telephony_announce_handler(&actor_tx).await; + + let mut helper = PythonTelephoneHost::spawn(port); + let ready = helper.wait_event("READY", Duration::from_secs(10)); + assert_eq!(ready["headless_audio"], true); + let remote_destination_hash = hash_from_ready(&ready, "destination_hash"); + let remote_identity_hash = hash_from_ready(&ready, "identity_hash"); + + let accepted = tokio::time::timeout(Duration::from_secs(15), handle_rx.recv()) + .await + .expect("timeout waiting for Python Telephone helper TCP connection") + .expect("TCP accept channel closed"); + let (accepted_id, accepted_entry, accepted_read_task) = register_interface_entry(accepted); + actor_tx + .send(TransportMessage::RegisterInterface { + id: accepted_id, + entry: accepted_entry, + }) + .await + .expect("register accepted Python TCP interface"); + + helper.send(json!({"cmd": "announce", "id": "announce-1"})); + let announced = helper.wait_event("ANNOUNCED", Duration::from_secs(10)); + assert_eq!( + announced["destination_hash"], + hex::encode(remote_destination_hash) + ); + let announce = tokio::time::timeout(Duration::from_secs(15), announce_rx.recv()) + .await + .unwrap_or_else(|_| panic!("Rust transport never received Python announce")) + .expect("announce handler channel closed"); + assert_eq!(announce.destination_hash, remote_destination_hash); + + let local_identity = Identity::new(); + let endpoint = TelephonyRnsEndpoint::register(actor_tx.clone(), &local_identity) + .expect("register Rust LXST Telephone endpoint"); + let rust_destination_hash = endpoint.destination_hash; + let (control_tx, control_rx) = mpsc::channel(16); + let (event_tx, mut event_rx) = mpsc::channel(64); + let service = TelephonyService::with_config( + endpoint, + TelephonyRuntimeCore::new(), + control_rx, + event_tx, + TelephonyServiceConfig { + poll_interval: Duration::from_millis(20), + incoming_ring_timeout: Some(Duration::from_secs(10)), + outgoing_call_timeout: Some(Duration::from_secs(10)), + media_frames_per_tick: 4, + ..TelephonyServiceConfig::default() + }, + ); + let service_task = tokio::spawn(service.run()); + + control_tx + .send(TelephonyControl::Call { + remote_identity: remote_identity_hash, + profile: None, + discovery_timeout: Duration::from_secs(5), + }) + .await + .expect("send Rust Telephone service call control"); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "outgoing call started", + |event| matches!(event, TelephonyServiceEvent::OutgoingCallStarted { .. }), + ) + .await; + + helper.wait_event("RINGING", Duration::from_secs(5)); + helper.send(json!({"cmd": "answer", "id": "answer-media-1"})); + let answered = helper.wait_event("ANSWERED", Duration::from_secs(5)); + assert_eq!(answered["accepted"], true); + helper.wait_event("ESTABLISHED", Duration::from_secs(5)); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "established snapshot", + |event| { + matches!( + event, + TelephonyServiceEvent::Snapshot(snapshot) + if snapshot.active_call.as_ref().is_some_and(|call| call.status == SignallingStatus::Established) + ) + }, + ) + .await; + + helper.send(json!({ + "cmd": "send_raw_frame", + "id": "py-raw-1", + "channels": 2, + "bitdepth": 32, + "samples": [0.0, 0.5, -0.25, 1.0], + })); + let raw_sent = helper.wait_event("RAW_FRAME_SENT", Duration::from_secs(5)); + assert_eq!(raw_sent["sent"], true); + + let drive = wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "Rust media drive", + |event| { + matches!( + event, + TelephonyServiceEvent::Drive(step) + if step.step.inbound.as_ref().is_some_and(|inbound| { + inbound + .frame_events + .iter() + .any(|event| matches!(event, FrameStreamEvent::Frame(_))) + }) + ) + }, + ) + .await; + let TelephonyServiceEvent::Drive(step) = drive else { + panic!("expected Drive event with inbound media"); + }; + let inbound = step.step.inbound.expect("inbound LXST media packet"); + let raw_frames = inbound + .frame_events + .iter() + .filter_map(|event| match event { + FrameStreamEvent::Frame(frame) => Some(RawAudioFrame::from_frame(frame).unwrap()), + _ => None, + }) + .collect::>(); + assert_eq!( + raw_frames, + vec![RawAudioFrame::new(2, vec![0.0, 0.5, -0.25, 1.0]).unwrap()] + ); + + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(5), + "media received", + |event| { + matches!( + event, + TelephonyServiceEvent::MediaReceived { frames: 1, .. } + ) + }, + ) + .await; + + control_tx + .send(TelephonyControl::Shutdown) + .await + .expect("send Rust Telephone service shutdown"); + wait_matching_service_event( + &mut event_rx, + Duration::from_secs(2), + "service stopped", + |event| matches!(event, TelephonyServiceEvent::Stopped), + ) + .await; + service_task.await.expect("join Rust Telephone service"); + actor_tx + .send(TransportMessage::DeregisterDestination { + hash: rust_destination_hash, + }) + .await + .expect("deregister Rust Telephone endpoint"); + accepted_read_task.abort(); + server.read_task.abort(); +} + +#[serial_test::serial(python_lxst_live)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rust_service_decodes_python_opus_voice_profile_matrix() { + if should_skip() { + eprintln!("{SKIP_ENV}=1 -> skipping live Python Opus media/Rust decoder interop"); + return; + } + + for profile in OPUS_VOICE_PROFILES { + let Some(mut session) = RustCallerSession::establish(profile).await else { + return; + }; + session.send_python_opus_to_rust().await; + session.shutdown().await; + } +}