diff --git a/.github/workflows/python-build-test.yml b/.github/workflows/python-build-test.yml index 72c7b43..8265769 100644 --- a/.github/workflows/python-build-test.yml +++ b/.github/workflows/python-build-test.yml @@ -41,3 +41,20 @@ jobs: run: | inv build inv build.mkdocs + build-rust: + runs-on: ubuntu-latest + steps: + - name: Check out from Git + uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install ".[build,test,development,documentation]" + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Rust Tests + run: cd rust && cargo build --all-targets && cargo test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 43b6410..97dc64d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ __pycache__ # generated by setuptools_scm bumble/_version.py .vscode/launch.json +/.idea diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..40d9aca --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,2 @@ +/target +/.idea \ No newline at end of file diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..1492cfb --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,1235 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aho-corasick" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is-terminal", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" + +[[package]] +name = "anstyle-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" + +[[package]] +name = "bumble" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap 4.3.17", + "env_logger", + "hex", + "itertools", + "lazy_static", + "log", + "nix", + "nom", + "owo-colors", + "pyo3", + "pyo3-asyncio", + "rand", + "rusb", + "strum", + "strum_macros", + "tempfile", + "thiserror", + "tokio", +] + +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" + +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" +dependencies = [ + "atty", + "bitflags 1.3.2", + "clap_lex 0.2.4", + "indexmap", + "strsim", + "termcolor", + "textwrap", +] + +[[package]] +name = "clap" +version = "4.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0827b011f6f8ab38590295339817b0d26f344aa4932c3ced71b45b0c54b4a9" +dependencies = [ + "clap_builder", + "clap_derive", + "once_cell", +] + +[[package]] +name = "clap_builder" +version = "4.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9441b403be87be858db6a23edb493e7f694761acdc3343d5a0fcaafd304cbc9e" +dependencies = [ + "anstream", + "anstyle", + "clap_lex 0.5.0", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a9bb5758fc5dfe728d1019941681eccaf0cf8a4189b692a0ee2f2ecf90a050" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.26", +] + +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "clap_lex" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" + +[[package]] +name = "env_logger" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "futures" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" + +[[package]] +name = "futures-executor" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" + +[[package]] +name = "futures-macro" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.26", +] + +[[package]] +name = "futures-sink" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" + +[[package]] +name = "futures-task" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" + +[[package]] +name = "futures-util" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "inventory" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b1d6b4b9fb75fc419bdef998b689df5080a32931cb3395b86202046b56a9ea" + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.2", + "libc", + "windows-sys", +] + +[[package]] +name = "is-terminal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +dependencies = [ + "hermit-abi 0.3.2", + "rustix 0.38.4", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "libusb1-sys" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d0e2afce4245f2c9a418511e5af8718bcaf2fa408aefb259504d1a9cb25f27" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" + +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61c719bcfbcf5d62b3a09efa6088de8c54bc0bfcd3ea7ae39fcc186108b8de1" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfdda3d196821d6af13126e40375cdf7da646a96114af134d5f417a9a1dc8e1a" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", + "static_assertions", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi 0.3.2", + "libc", +] + +[[package]] +name = "object" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "os_str_bytes" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d5d9eb14b174ee9aa2ef96dc2b94637a2d4b6e7cb873c7e171f0c20c6cf3eac" + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b1ac5b3731ba34fdaa9785f8d74d17448cd18f30cf19e0c7e7b1fdb5272109" +dependencies = [ + "anyhow", + "cfg-if", + "indoc", + "libc", + "memoffset 0.8.0", + "parking_lot", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-asyncio" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3564762e37035cfc486228e10b0528460fa026d681b5763873c693aa0d5c260" +dependencies = [ + "clap 3.2.25", + "futures", + "inventory", + "once_cell", + "pin-project-lite", + "pyo3", + "pyo3-asyncio-macros", + "tokio", +] + +[[package]] +name = "pyo3-asyncio-macros" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be72d4cd43a27530306bd0d20d3932182fbdd072c6b98d3638bc37efb9d559dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pyo3-build-config" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb946f5ac61bb61a5014924910d936ebd2b23b705f7a4a3c40b05c720b079a3" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd4d7c5337821916ea2a1d21d1092e8443cf34879e53a0ac653fbb98f44ff65c" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d39c55dab3fc5a4b25bbd1ac10a2da452c4aca13bb450f22818a002e29648d" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97daff08a4c48320587b5224cc98d609e3c27b6d437315bd40b605c98eeb5918" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fe8a65d69dd0808184ebb5f836ab526bb259db23c657efa38711b1072ee47f0" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "regex" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39354c10dd07468c2e73926b23bb9c2caca74c5501e38a35da70406f1d923310" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" + +[[package]] +name = "rusb" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44a8c36914f9b1a3be712c1dfa48c9b397131f9a75707e570a391735f785c5d1" +dependencies = [ + "libc", + "libusb1-sys", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustix" +version = "0.37.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d69718bf81c6127a49dc64e44a742e8bb9213c0ff8869a22c308f84c1d4ab06" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys", +] + +[[package]] +name = "rustix" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a962918ea88d644592894bc6dc55acc6c0956488adcebbfb6e273506b7fd6e5" +dependencies = [ + "bitflags 2.3.3", + "errno", + "libc", + "linux-raw-sys 0.4.3", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" + +[[package]] +name = "strum_macros" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6069ca09d878a33f883cc06aaa9718ede171841d3832450354410b718b097232" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.26", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c3457aacde3c65315de5031ec191ce46604304d2446e803d71ade03308d970" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2faeef5759ab89935255b1a4cd98e0baf99d1085e37d36599c625dac49ae8e" + +[[package]] +name = "tempfile" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31c0432476357e58790aaa47a8efb0c5138f137343f3b5f23bd36a27e3b0a6d6" +dependencies = [ + "autocfg", + "cfg-if", + "fastrand", + "redox_syscall", + "rustix 0.37.23", + "windows-sys", +] + +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" + +[[package]] +name = "thiserror" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.26", +] + +[[package]] +name = "tokio" +version = "1.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da" +dependencies = [ + "autocfg", + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.26", +] + +[[package]] +name = "unicode-ident" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" + +[[package]] +name = "unindent" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..8b7c723 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "bumble" +description = "Rust API for the Bumble Bluetooth stack" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +homepage = "https://google.github.io/bumble/index.html" +repository = "https://github.com/google/bumble" +documentation = "https://docs.rs/crate/bumble" +authors = ["Marshall Pierce "] +keywords = ["bluetooth", "ble"] +categories = ["api-bindings", "network-programming"] +rust-version = "1.69.0" + +[dependencies] +pyo3 = { version = "0.18.3", features = ["macros"] } +pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] } +tokio = { version = "1.28.2" } +nom = "7.1.3" +strum = "0.25.0" +strum_macros = "0.25.0" +hex = "0.4.3" +itertools = "0.11.0" +lazy_static = "1.4.0" +thiserror = "1.0.41" + +[dev-dependencies] +tokio = { version = "1.28.2", features = ["full"] } +tempfile = "3.6.0" +nix = "0.26.2" +anyhow = "1.0.71" +pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] } +pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] } +clap = { version = "4.3.3", features = ["derive"] } +owo-colors = "3.5.0" +log = "0.4.19" +env_logger = "0.10.0" +rusb = "0.9.2" +rand = "0.8.5" + +# test entry point that uses pyo3_asyncio's test harness +[[test]] +name = "pytests" +path = "pytests/pytests.rs" +harness = false + +[features] +anyhow = ["pyo3/anyhow"] +pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"] \ No newline at end of file diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..513810a --- /dev/null +++ b/rust/README.md @@ -0,0 +1,42 @@ +# What is this? + +Rust wrappers around the [Bumble](https://github.com/google/bumble) Python API. + +Method calls are mapped to the equivalent Python, and return types adapted where +relevant. + +See the `examples` directory for usage. + +# Usage + +Set up a virtualenv for Bumble, or otherwise have an isolated Python environment +fur Bumble and its dependencies. + +Due to Python being +[picky about how its sys path is set up](https://github.com/PyO3/pyo3/issues/1741, +it's necessary to explicitly point to the virtualenv's `site-packages`. Use +suitable virtualenv paths as appropriate for your OS, as seen here running +the `battery_client` example: + +``` +PYTHONPATH=..:~/.virtualenvs/bumble/lib/python3.10/site-packages/ \ + cargo run --example battery_client -- \ + --transport android-netsim --target-addr F0:F1:F2:F3:F4:F5 +``` + +Run the corresponding `battery_server` Python example, and launch an emulator in +Android Studio (currently, Canary is required) to run netsim. + +# Development + +Run the tests: + +``` +PYTHONPATH=.. cargo test +``` + +Check lints: + +``` +cargo clippy --all-targets +``` \ No newline at end of file diff --git a/rust/examples/battery_client.rs b/rust/examples/battery_client.rs new file mode 100644 index 0000000..7730aea --- /dev/null +++ b/rust/examples/battery_client.rs @@ -0,0 +1,113 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Counterpart to the Python example `battery_server.py`. +//! +//! Start an Android emulator from Android Studio, or otherwise have netsim running. +//! +//! Run the server from the project root: +//! ``` +//! PYTHONPATH=. python examples/battery_server.py \ +//! examples/device1.json android-netsim +//! ``` +//! +//! Then run this example from the `rust` directory: +//! +//! ``` +//! PYTHONPATH=..:/path/to/virtualenv/site-packages/ \ +//! cargo run --example battery_client -- \ +//! --transport android-netsim \ +//! --target-addr F0:F1:F2:F3:F4:F5 +//! ``` + +use bumble::wrapper::{ + device::{Device, Peer}, + profile::BatteryService, + transport::Transport, + PyObjectExt, +}; +use clap::Parser as _; +use log::info; +use owo_colors::OwoColorize; +use pyo3::prelude::*; + +#[pyo3_asyncio::tokio::main] +async fn main() -> PyResult<()> { + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .init(); + + let cli = Cli::parse(); + + let transport = Transport::open(cli.transport).await?; + + let device = Device::with_hci( + "Bumble", + "F0:F1:F2:F3:F4:F5", + transport.source()?, + transport.sink()?, + )?; + + device.power_on().await?; + + let conn = device.connect(&cli.target_addr).await?; + let mut peer = Peer::new(conn)?; + peer.discover_services().await?; + for mut s in peer.services()? { + s.discover_characteristics().await?; + } + let battery_service = peer + .create_service_proxy::()? + .ok_or(anyhow::anyhow!("No battery service found"))?; + + let mut battery_level_char = battery_service + .battery_level()? + .ok_or(anyhow::anyhow!("No battery level characteristic"))?; + info!( + "{} {}", + "Initial Battery Level:".green(), + battery_level_char + .read_value() + .await? + .extract_with_gil::()? + ); + battery_level_char + .subscribe(|_py, args| { + info!( + "{} {:?}", + "Battery level update:".green(), + args.get_item(0)?.extract::()?, + ); + Ok(()) + }) + .await?; + + // wait until user kills the process + tokio::signal::ctrl_c().await?; + Ok(()) +} + +#[derive(clap::Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// Bumble transport spec. + /// + /// + #[arg(long)] + transport: String, + + /// Address to connect to + #[arg(long)] + target_addr: String, +} diff --git a/rust/examples/broadcast.rs b/rust/examples/broadcast.rs new file mode 100644 index 0000000..9b1acdf --- /dev/null +++ b/rust/examples/broadcast.rs @@ -0,0 +1,96 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use anyhow::anyhow; +use bumble::wrapper::{ + adv::{AdvertisementDataBuilder, CommonDataType}, + device::Device, + logging::{bumble_env_logging_level, py_logging_basic_config}, + transport::Transport, +}; +use clap::Parser as _; +use pyo3::PyResult; +use rand::Rng; +use std::path; + +#[pyo3_asyncio::tokio::main] +async fn main() -> PyResult<()> { + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .init(); + + let cli = Cli::parse(); + + if cli.log_hci { + py_logging_basic_config(bumble_env_logging_level("DEBUG"))?; + } + + let transport = Transport::open(cli.transport).await?; + + let mut device = Device::from_config_file_with_hci( + &cli.device_config, + transport.source()?, + transport.sink()?, + )?; + + let mut adv_data = AdvertisementDataBuilder::new(); + + adv_data + .append( + CommonDataType::CompleteLocalName, + "Bumble from Rust".as_bytes(), + ) + .map_err(|e| anyhow!(e))?; + + // Randomized TX power + adv_data + .append( + CommonDataType::TxPowerLevel, + &[rand::thread_rng().gen_range(-100_i8..=20) as u8], + ) + .map_err(|e| anyhow!(e))?; + + device.set_advertisement(adv_data)?; + device.power_on().await?; + + println!("Advertising..."); + device.start_advertising(true).await?; + + // wait until user kills the process + tokio::signal::ctrl_c().await?; + + println!("Stopping..."); + device.stop_advertising().await?; + + Ok(()) +} + +#[derive(clap::Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// Bumble device config. + /// + /// See, for instance, `examples/device1.json` in the Python project. + #[arg(long)] + device_config: path::PathBuf, + /// Bumble transport spec. + /// + /// + #[arg(long)] + transport: String, + + /// Log HCI commands + #[arg(long)] + log_hci: bool, +} diff --git a/rust/examples/scanner.rs b/rust/examples/scanner.rs new file mode 100644 index 0000000..0b2a112 --- /dev/null +++ b/rust/examples/scanner.rs @@ -0,0 +1,180 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Counterpart to the Python example `run_scanner.py` + +use bumble::wrapper::{ + adv::CommonDataType, core::AdvertisementDataUnit, device::Device, hci::AddressType, + transport::Transport, +}; +use clap::Parser as _; +use itertools::Itertools; +use owo_colors::{OwoColorize, Style}; +use pyo3::PyResult; +use std::{ + collections, + sync::{Arc, Mutex}, + time, +}; + +#[pyo3_asyncio::tokio::main] +async fn main() -> PyResult<()> { + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .init(); + + let cli = Cli::parse(); + + let transport = Transport::open(cli.transport).await?; + + let mut device = Device::with_hci( + "Bumble", + "F0:F1:F2:F3:F4:F5", + transport.source()?, + transport.sink()?, + )?; + + // in practice, devices can send multiple advertisements from the same address, so we keep + // track of a timestamp for each set of data + let seen_advertisements = Arc::new(Mutex::new(collections::HashMap::< + Vec, + collections::HashMap, time::Instant>, + >::new())); + + let seen_adv_clone = seen_advertisements.clone(); + device.on_advertisement(move |_py, adv| { + let rssi = adv.rssi()?; + let data_units = adv.data()?.data_units()?; + let addr = adv.address()?; + + let show_adv = if cli.filter_duplicates { + let addr_bytes = addr.as_le_bytes()?; + + let mut seen_adv_cache = seen_adv_clone.lock().unwrap(); + let expiry_duration = time::Duration::from_secs(cli.dedup_expiry_secs); + + let advs_from_addr = seen_adv_cache + .entry(addr_bytes) + .or_insert_with(collections::HashMap::new); + // we expect cache hits to be the norm, so we do a separate lookup to avoid cloning + // on every lookup with entry() + let show = if let Some(prev) = advs_from_addr.get_mut(&data_units) { + let expired = prev.elapsed() > expiry_duration; + *prev = time::Instant::now(); + expired + } else { + advs_from_addr.insert(data_units.clone(), time::Instant::now()); + true + }; + + // clean out anything we haven't seen in a while + advs_from_addr.retain(|_, instant| instant.elapsed() <= expiry_duration); + + show + } else { + true + }; + + if !show_adv { + return Ok(()); + } + + let addr_style = if adv.is_connectable()? { + Style::new().yellow() + } else { + Style::new().red() + }; + + let (type_style, qualifier) = match adv.address()?.address_type()? { + AddressType::PublicIdentity | AddressType::PublicDevice => (Style::new().cyan(), ""), + _ => { + if addr.is_static()? { + (Style::new().green(), "(static)") + } else if addr.is_resolvable()? { + (Style::new().magenta(), "(resolvable)") + } else { + (Style::new().default_color(), "") + } + } + }; + + println!( + ">>> {} [{:?}] {qualifier}:\n RSSI: {}", + addr.as_hex()?.style(addr_style), + addr.address_type()?.style(type_style), + rssi, + ); + + data_units.into_iter().for_each(|(code, data)| { + let matching = CommonDataType::for_type_code(code).collect::>(); + let code_str = if matching.is_empty() { + format!("0x{}", hex::encode_upper([code.into()])) + } else { + matching + .iter() + .map(|t| format!("{}", t)) + .join(" / ") + .blue() + .to_string() + }; + + // use the first matching type's formatted data, if any + let data_str = matching + .iter() + .filter_map(|t| { + t.format_data(&data).map(|formatted| { + format!( + "{} {}", + formatted, + format!("(raw: 0x{})", hex::encode_upper(&data)).dimmed() + ) + }) + }) + .next() + .unwrap_or_else(|| format!("0x{}", hex::encode_upper(&data))); + + println!(" [{}]: {}", code_str, data_str) + }); + + Ok(()) + })?; + + device.power_on().await?; + // do our own dedup + device.start_scanning(false).await?; + + // wait until user kills the process + tokio::signal::ctrl_c().await?; + + Ok(()) +} + +#[derive(clap::Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// Bumble transport spec. + /// + /// + #[arg(long)] + transport: String, + + /// Filter duplicate advertisements + #[arg(long, default_value_t = false)] + filter_duplicates: bool, + + /// How long before a deduplicated advertisement that hasn't been seen in a while is considered + /// fresh again, in seconds + #[arg(long, default_value_t = 10, requires = "filter_duplicates")] + dedup_expiry_secs: u64, +} diff --git a/rust/examples/usb_probe.rs b/rust/examples/usb_probe.rs new file mode 100644 index 0000000..dd0dd08 --- /dev/null +++ b/rust/examples/usb_probe.rs @@ -0,0 +1,342 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Rust version of the Python `usb_probe.py`. +//! +//! This tool lists all the USB devices, with details about each device. +//! For each device, the different possible Bumble transport strings that can +//! refer to it are listed. If the device is known to be a Bluetooth HCI device, +//! its identifier is printed in reverse colors, and the transport names in cyan color. +//! For other devices, regardless of their type, the transport names are printed +//! in red. Whether that device is actually a Bluetooth device or not depends on +//! whether it is a Bluetooth device that uses a non-standard Class, or some other +//! type of device (there's no way to tell). + +use clap::Parser as _; +use itertools::Itertools as _; +use owo_colors::{OwoColorize, Style}; +use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext}; +use std::{ + collections::{HashMap, HashSet}, + time::Duration, +}; + +const USB_DEVICE_CLASS_DEVICE: u8 = 0x00; +const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0; +const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01; +const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01; + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let mut bt_dev_count = 0; + let mut device_serials_by_id: HashMap<(u16, u16), HashSet> = HashMap::new(); + for device in rusb::devices()?.iter() { + let device_desc = device.device_descriptor().unwrap(); + + let class_info = ClassInfo::from(&device_desc); + let handle = device.open()?; + let timeout = Duration::from_secs(1); + // some devices don't have languages + let lang = handle + .read_languages(timeout) + .ok() + .and_then(|langs| langs.into_iter().next()); + let serial = lang.and_then(|l| { + handle + .read_serial_number_string(l, &device_desc, timeout) + .ok() + }); + let mfg = lang.and_then(|l| { + handle + .read_manufacturer_string(l, &device_desc, timeout) + .ok() + }); + let product = lang.and_then(|l| handle.read_product_string(l, &device_desc, timeout).ok()); + + let is_hci = is_bluetooth_hci(&device, &device_desc)?; + let addr_style = if is_hci { + bt_dev_count += 1; + Style::new().black().on_yellow() + } else { + Style::new().yellow().on_black() + }; + + let mut transport_names = Vec::new(); + let basic_transport_name = format!( + "usb:{:04X}:{:04X}", + device_desc.vendor_id(), + device_desc.product_id() + ); + + if is_hci { + transport_names.push(format!("usb:{}", bt_dev_count - 1)); + } + + let device_id = (device_desc.vendor_id(), device_desc.product_id()); + if !device_serials_by_id.contains_key(&device_id) { + transport_names.push(basic_transport_name.clone()); + } else { + transport_names.push(format!( + "{}/{}", + basic_transport_name, + device_serials_by_id + .get(&device_id) + .map(|serials| serials.len()) + .unwrap_or(0) + )) + } + + if let Some(s) = &serial { + if !device_serials_by_id + .get(&device_id) + .map(|serials| serials.contains(s)) + .unwrap_or(false) + { + transport_names.push(format!("{}/{}", basic_transport_name, s)) + } + } + + println!( + "{}", + format!( + "ID {:04X}:{:04X}", + device_desc.vendor_id(), + device_desc.product_id() + ) + .style(addr_style) + ); + if !transport_names.is_empty() { + let style = if is_hci { + Style::new().cyan() + } else { + Style::new().red() + }; + println!( + "{:26 }{}", + " Bumble Transport Names:".blue(), + transport_names.iter().map(|n| n.style(style)).join(" or ") + ) + } + println!( + "{:26 }{:03}/{:03}", + " Bus/Device:".green(), + device.bus_number(), + device.address() + ); + println!( + "{:26 }{}", + " Class:".green(), + class_info.formatted_class_name() + ); + println!( + "{:26 }{}", + " Subclass/Protocol:".green(), + class_info.formatted_subclass_protocol() + ); + if let Some(s) = serial { + println!("{:26 }{}", " Serial:".green(), s); + device_serials_by_id + .entry(device_id) + .or_insert(HashSet::new()) + .insert(s); + } + if let Some(m) = mfg { + println!("{:26 }{}", " Manufacturer:".green(), m); + } + if let Some(p) = product { + println!("{:26 }{}", " Product:".green(), p); + } + + if cli.verbose { + print_device_details(&device, &device_desc)?; + } + + println!(); + } + + Ok(()) +} + +fn is_bluetooth_hci( + device: &Device, + device_desc: &DeviceDescriptor, +) -> rusb::Result { + if device_desc.class_code() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER + && device_desc.sub_class_code() == USB_DEVICE_SUBCLASS_RF_CONTROLLER + && device_desc.protocol_code() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER + { + Ok(true) + } else if device_desc.class_code() == USB_DEVICE_CLASS_DEVICE { + for i in 0..device_desc.num_configurations() { + for interface in device.config_descriptor(i)?.interfaces() { + for d in interface.descriptors() { + if d.class_code() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER + && d.sub_class_code() == USB_DEVICE_SUBCLASS_RF_CONTROLLER + && d.protocol_code() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER + { + return Ok(true); + } + } + } + } + + Ok(false) + } else { + Ok(false) + } +} + +fn print_device_details( + device: &Device, + device_desc: &DeviceDescriptor, +) -> anyhow::Result<()> { + for i in 0..device_desc.num_configurations() { + println!(" Configuration {}", i + 1); + for interface in device.config_descriptor(i)?.interfaces() { + let descriptors: Vec<_> = interface.descriptors().collect(); + for d in &descriptors { + let class_info = + ClassInfo::new(d.class_code(), d.sub_class_code(), d.protocol_code()); + + println!( + " Interface: {}{} ({}, {})", + interface.number(), + if descriptors.len() > 1 { + format!("/{}", d.setting_number()) + } else { + String::new() + }, + class_info.formatted_class_name(), + class_info.formatted_subclass_protocol() + ); + + for e in d.endpoint_descriptors() { + println!( + " Endpoint {:#04X}: {} {}", + e.address(), + match e.transfer_type() { + TransferType::Control => "CONTROL", + TransferType::Isochronous => "ISOCHRONOUS", + TransferType::Bulk => "BULK", + TransferType::Interrupt => "INTERRUPT", + }, + match e.direction() { + Direction::In => "IN", + Direction::Out => "OUT", + } + ) + } + } + } + } + + Ok(()) +} + +struct ClassInfo { + class: u8, + sub_class: u8, + protocol: u8, +} + +impl ClassInfo { + fn new(class: u8, sub_class: u8, protocol: u8) -> Self { + Self { + class, + sub_class, + protocol, + } + } + + fn class_name(&self) -> Option<&str> { + match self.class { + 0x00 => Some("Device"), + 0x01 => Some("Audio"), + 0x02 => Some("Communications and CDC Control"), + 0x03 => Some("Human Interface Device"), + 0x05 => Some("Physical"), + 0x06 => Some("Still Imaging"), + 0x07 => Some("Printer"), + 0x08 => Some("Mass Storage"), + 0x09 => Some("Hub"), + 0x0A => Some("CDC Data"), + 0x0B => Some("Smart Card"), + 0x0D => Some("Content Security"), + 0x0E => Some("Video"), + 0x0F => Some("Personal Healthcare"), + 0x10 => Some("Audio/Video"), + 0x11 => Some("Billboard"), + 0x12 => Some("USB Type-C Bridge"), + 0x3C => Some("I3C"), + 0xDC => Some("Diagnostic"), + USB_DEVICE_CLASS_WIRELESS_CONTROLLER => Some("Wireless Controller"), + 0xEF => Some("Miscellaneous"), + 0xFE => Some("Application Specific"), + 0xFF => Some("Vendor Specific"), + _ => None, + } + } + + fn protocol_name(&self) -> Option<&str> { + match self.class { + USB_DEVICE_CLASS_WIRELESS_CONTROLLER => match self.sub_class { + 0x01 => match self.protocol { + 0x01 => Some("Bluetooth"), + 0x02 => Some("UWB"), + 0x03 => Some("Remote NDIS"), + 0x04 => Some("Bluetooth AMP"), + _ => None, + }, + _ => None, + }, + _ => None, + } + } + + fn formatted_class_name(&self) -> String { + self.class_name() + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("{:#04X}", self.class)) + } + + fn formatted_subclass_protocol(&self) -> String { + format!( + "{}/{}{}", + self.sub_class, + self.protocol, + self.protocol_name() + .map(|s| format!(" [{}]", s)) + .unwrap_or_else(String::new) + ) + } +} + +impl From<&DeviceDescriptor> for ClassInfo { + fn from(value: &DeviceDescriptor) -> Self { + Self::new( + value.class_code(), + value.sub_class_code(), + value.protocol_code(), + ) + } +} + +#[derive(clap::Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// Show additional info for each USB device + #[arg(long, default_value_t = false)] + verbose: bool, +} diff --git a/rust/pytests/pytests.rs b/rust/pytests/pytests.rs new file mode 100644 index 0000000..da331f3 --- /dev/null +++ b/rust/pytests/pytests.rs @@ -0,0 +1,20 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[pyo3_asyncio::tokio::main] +async fn main() -> pyo3::PyResult<()> { + pyo3_asyncio::testing::main().await +} + +mod wrapper; diff --git a/rust/pytests/wrapper.rs b/rust/pytests/wrapper.rs new file mode 100644 index 0000000..1c1f9d0 --- /dev/null +++ b/rust/pytests/wrapper.rs @@ -0,0 +1,37 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use bumble::{wrapper, wrapper::transport::Transport}; +use nix::sys::stat::Mode; +use pyo3::prelude::*; + +#[pyo3_asyncio::tokio::test] +async fn fifo_transport_can_open() -> PyResult<()> { + let dir = tempfile::tempdir().unwrap(); + let mut fifo = dir.path().to_path_buf(); + fifo.push("bumble-transport-fifo"); + nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap(); + + let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?; + + t.close().await?; + + Ok(()) +} + +#[pyo3_asyncio::tokio::test] +async fn company_ids() -> PyResult<()> { + assert!(wrapper::assigned_numbers::COMPANY_IDS.len() > 2000); + Ok(()) +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..c92d138 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,29 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Rust API for [Bumble](https://github.com/google/bumble). +//! +//! Bumble is a userspace Bluetooth stack that works with more or less anything that uses HCI. This +//! could be physical Bluetooth USB dongles, netsim, HCI proxied over a network from some device +//! elsewhere, etc. +//! +//! It also does not restrict what you can do with Bluetooth the way that OS Bluetooth APIs +//! typically do, making it good for prototyping, experimentation, test tools, etc. +//! +//! Bumble is primarily written in Python. Rust types that wrap the Python API, which is currently +//! all of them, are in the [wrapper] module. + +#![deny(missing_docs, unsafe_code)] + +pub mod wrapper; diff --git a/rust/src/wrapper/adv.rs b/rust/src/wrapper/adv.rs new file mode 100644 index 0000000..a37db29 --- /dev/null +++ b/rust/src/wrapper/adv.rs @@ -0,0 +1,440 @@ +//! Advertisements + +use crate::wrapper::{ + assigned_numbers::{COMPANY_IDS, SERVICE_IDS}, + core::{Uuid128, Uuid16, Uuid32}, +}; +use itertools::Itertools; +use nom::{combinator, multi, number}; +use std::fmt; +use strum::IntoEnumIterator; + +/// The numeric code for a common data type. +/// +/// For known types, see [CommonDataType], or use this type directly for non-assigned codes. +#[derive(PartialEq, Eq, Debug, Clone, Copy, Hash)] +pub struct CommonDataTypeCode(u8); + +impl From for CommonDataTypeCode { + fn from(value: CommonDataType) -> Self { + let byte = match value { + CommonDataType::Flags => 0x01, + CommonDataType::IncompleteListOf16BitServiceClassUuids => 0x02, + CommonDataType::CompleteListOf16BitServiceClassUuids => 0x03, + CommonDataType::IncompleteListOf32BitServiceClassUuids => 0x04, + CommonDataType::CompleteListOf32BitServiceClassUuids => 0x05, + CommonDataType::IncompleteListOf128BitServiceClassUuids => 0x06, + CommonDataType::CompleteListOf128BitServiceClassUuids => 0x07, + CommonDataType::ShortenedLocalName => 0x08, + CommonDataType::CompleteLocalName => 0x09, + CommonDataType::TxPowerLevel => 0x0A, + CommonDataType::ClassOfDevice => 0x0D, + CommonDataType::SimplePairingHashC192 => 0x0E, + CommonDataType::SimplePairingRandomizerR192 => 0x0F, + // These two both really have type code 0x10! D: + CommonDataType::DeviceId => 0x10, + CommonDataType::SecurityManagerTkValue => 0x10, + CommonDataType::SecurityManagerOutOfBandFlags => 0x11, + CommonDataType::PeripheralConnectionIntervalRange => 0x12, + CommonDataType::ListOf16BitServiceSolicitationUuids => 0x14, + CommonDataType::ListOf128BitServiceSolicitationUuids => 0x15, + CommonDataType::ServiceData16BitUuid => 0x16, + CommonDataType::PublicTargetAddress => 0x17, + CommonDataType::RandomTargetAddress => 0x18, + CommonDataType::Appearance => 0x19, + CommonDataType::AdvertisingInterval => 0x1A, + CommonDataType::LeBluetoothDeviceAddress => 0x1B, + CommonDataType::LeRole => 0x1C, + CommonDataType::SimplePairingHashC256 => 0x1D, + CommonDataType::SimplePairingRandomizerR256 => 0x1E, + CommonDataType::ListOf32BitServiceSolicitationUuids => 0x1F, + CommonDataType::ServiceData32BitUuid => 0x20, + CommonDataType::ServiceData128BitUuid => 0x21, + CommonDataType::LeSecureConnectionsConfirmationValue => 0x22, + CommonDataType::LeSecureConnectionsRandomValue => 0x23, + CommonDataType::Uri => 0x24, + CommonDataType::IndoorPositioning => 0x25, + CommonDataType::TransportDiscoveryData => 0x26, + CommonDataType::LeSupportedFeatures => 0x27, + CommonDataType::ChannelMapUpdateIndication => 0x28, + CommonDataType::PbAdv => 0x29, + CommonDataType::MeshMessage => 0x2A, + CommonDataType::MeshBeacon => 0x2B, + CommonDataType::BigInfo => 0x2C, + CommonDataType::BroadcastCode => 0x2D, + CommonDataType::ResolvableSetIdentifier => 0x2E, + CommonDataType::AdvertisingIntervalLong => 0x2F, + CommonDataType::ThreeDInformationData => 0x3D, + CommonDataType::ManufacturerSpecificData => 0xFF, + }; + + Self(byte) + } +} + +impl From for CommonDataTypeCode { + fn from(value: u8) -> Self { + Self(value) + } +} + +impl From for u8 { + fn from(value: CommonDataTypeCode) -> Self { + value.0 + } +} + +/// Data types for assigned type codes. +/// +/// See Bluetooth Assigned Numbers ยง 2.3 +#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumIter)] +#[allow(missing_docs)] +pub enum CommonDataType { + Flags, + IncompleteListOf16BitServiceClassUuids, + CompleteListOf16BitServiceClassUuids, + IncompleteListOf32BitServiceClassUuids, + CompleteListOf32BitServiceClassUuids, + IncompleteListOf128BitServiceClassUuids, + CompleteListOf128BitServiceClassUuids, + ShortenedLocalName, + CompleteLocalName, + TxPowerLevel, + ClassOfDevice, + SimplePairingHashC192, + SimplePairingRandomizerR192, + DeviceId, + SecurityManagerTkValue, + SecurityManagerOutOfBandFlags, + PeripheralConnectionIntervalRange, + ListOf16BitServiceSolicitationUuids, + ListOf128BitServiceSolicitationUuids, + ServiceData16BitUuid, + PublicTargetAddress, + RandomTargetAddress, + Appearance, + AdvertisingInterval, + LeBluetoothDeviceAddress, + LeRole, + SimplePairingHashC256, + SimplePairingRandomizerR256, + ListOf32BitServiceSolicitationUuids, + ServiceData32BitUuid, + ServiceData128BitUuid, + LeSecureConnectionsConfirmationValue, + LeSecureConnectionsRandomValue, + Uri, + IndoorPositioning, + TransportDiscoveryData, + LeSupportedFeatures, + ChannelMapUpdateIndication, + PbAdv, + MeshMessage, + MeshBeacon, + BigInfo, + BroadcastCode, + ResolvableSetIdentifier, + AdvertisingIntervalLong, + ThreeDInformationData, + ManufacturerSpecificData, +} + +impl CommonDataType { + /// Iterate over the zero, one, or more matching types for the provided code. + /// + /// `0x10` maps to both Device Id and Security Manager TK Value, so multiple matching types + /// may exist for a single code. + pub fn for_type_code(code: CommonDataTypeCode) -> impl Iterator { + Self::iter().filter(move |t| CommonDataTypeCode::from(*t) == code) + } + + /// Apply type-specific human-oriented formatting to data, if any is applicable + pub fn format_data(&self, data: &[u8]) -> Option { + match self { + Self::Flags => Some(Flags::matching(data).map(|f| format!("{:?}", f)).join(", ")), + Self::CompleteListOf16BitServiceClassUuids + | Self::IncompleteListOf16BitServiceClassUuids + | Self::ListOf16BitServiceSolicitationUuids => { + combinator::complete(multi::many0(Uuid16::parse_le))(data) + .map(|(_res, uuids)| { + uuids + .into_iter() + .map(|uuid| { + SERVICE_IDS + .get(&uuid) + .map(|name| format!("{:?} ({name})", uuid)) + .unwrap_or_else(|| format!("{:?}", uuid)) + }) + .join(", ") + }) + .ok() + } + Self::CompleteListOf32BitServiceClassUuids + | Self::IncompleteListOf32BitServiceClassUuids + | Self::ListOf32BitServiceSolicitationUuids => { + combinator::complete(multi::many0(Uuid32::parse))(data) + .map(|(_res, uuids)| uuids.into_iter().map(|u| format!("{:?}", u)).join(", ")) + .ok() + } + Self::CompleteListOf128BitServiceClassUuids + | Self::IncompleteListOf128BitServiceClassUuids + | Self::ListOf128BitServiceSolicitationUuids => { + combinator::complete(multi::many0(Uuid128::parse_le))(data) + .map(|(_res, uuids)| uuids.into_iter().map(|u| format!("{:?}", u)).join(", ")) + .ok() + } + Self::ServiceData16BitUuid => Uuid16::parse_le(data) + .map(|(rem, uuid)| { + format!( + "service={:?}, data={}", + SERVICE_IDS + .get(&uuid) + .map(|name| format!("{:?} ({name})", uuid)) + .unwrap_or_else(|| format!("{:?}", uuid)), + hex::encode_upper(rem) + ) + }) + .ok(), + Self::ServiceData32BitUuid => Uuid32::parse(data) + .map(|(rem, uuid)| format!("service={:?}, data={}", uuid, hex::encode_upper(rem))) + .ok(), + Self::ServiceData128BitUuid => Uuid128::parse_le(data) + .map(|(rem, uuid)| format!("service={:?}, data={}", uuid, hex::encode_upper(rem))) + .ok(), + Self::ShortenedLocalName | Self::CompleteLocalName => { + std::str::from_utf8(data).ok().map(|s| format!("\"{}\"", s)) + } + Self::TxPowerLevel => { + let (_, tx) = + combinator::complete(number::complete::i8::<_, nom::error::Error<_>>)(data) + .ok()?; + + Some(tx.to_string()) + } + Self::ManufacturerSpecificData => { + let (rem, id) = Uuid16::parse_le(data).ok()?; + Some(format!( + "company={}, data=0x{}", + COMPANY_IDS + .get(&id) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("{:?}", id)), + hex::encode_upper(rem) + )) + } + _ => None, + } + } +} + +impl fmt::Display for CommonDataType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CommonDataType::Flags => write!(f, "Flags"), + CommonDataType::IncompleteListOf16BitServiceClassUuids => { + write!(f, "Incomplete List of 16-bit Service Class UUIDs") + } + CommonDataType::CompleteListOf16BitServiceClassUuids => { + write!(f, "Complete List of 16-bit Service Class UUIDs") + } + CommonDataType::IncompleteListOf32BitServiceClassUuids => { + write!(f, "Incomplete List of 32-bit Service Class UUIDs") + } + CommonDataType::CompleteListOf32BitServiceClassUuids => { + write!(f, "Complete List of 32-bit Service Class UUIDs") + } + CommonDataType::ListOf16BitServiceSolicitationUuids => { + write!(f, "List of 16-bit Service Solicitation UUIDs") + } + CommonDataType::ListOf32BitServiceSolicitationUuids => { + write!(f, "List of 32-bit Service Solicitation UUIDs") + } + CommonDataType::ListOf128BitServiceSolicitationUuids => { + write!(f, "List of 128-bit Service Solicitation UUIDs") + } + CommonDataType::IncompleteListOf128BitServiceClassUuids => { + write!(f, "Incomplete List of 128-bit Service Class UUIDs") + } + CommonDataType::CompleteListOf128BitServiceClassUuids => { + write!(f, "Complete List of 128-bit Service Class UUIDs") + } + CommonDataType::ShortenedLocalName => write!(f, "Shortened Local Name"), + CommonDataType::CompleteLocalName => write!(f, "Complete Local Name"), + CommonDataType::TxPowerLevel => write!(f, "TX Power Level"), + CommonDataType::ClassOfDevice => write!(f, "Class of Device"), + CommonDataType::SimplePairingHashC192 => { + write!(f, "Simple Pairing Hash C-192") + } + CommonDataType::SimplePairingHashC256 => { + write!(f, "Simple Pairing Hash C 256") + } + CommonDataType::SimplePairingRandomizerR192 => { + write!(f, "Simple Pairing Randomizer R-192") + } + CommonDataType::SimplePairingRandomizerR256 => { + write!(f, "Simple Pairing Randomizer R 256") + } + CommonDataType::DeviceId => write!(f, "Device Id"), + CommonDataType::SecurityManagerTkValue => { + write!(f, "Security Manager TK Value") + } + CommonDataType::SecurityManagerOutOfBandFlags => { + write!(f, "Security Manager Out of Band Flags") + } + CommonDataType::PeripheralConnectionIntervalRange => { + write!(f, "Peripheral Connection Interval Range") + } + CommonDataType::ServiceData16BitUuid => { + write!(f, "Service Data 16-bit UUID") + } + CommonDataType::ServiceData32BitUuid => { + write!(f, "Service Data 32-bit UUID") + } + CommonDataType::ServiceData128BitUuid => { + write!(f, "Service Data 128-bit UUID") + } + CommonDataType::PublicTargetAddress => write!(f, "Public Target Address"), + CommonDataType::RandomTargetAddress => write!(f, "Random Target Address"), + CommonDataType::Appearance => write!(f, "Appearance"), + CommonDataType::AdvertisingInterval => write!(f, "Advertising Interval"), + CommonDataType::LeBluetoothDeviceAddress => { + write!(f, "LE Bluetooth Device Address") + } + CommonDataType::LeRole => write!(f, "LE Role"), + CommonDataType::LeSecureConnectionsConfirmationValue => { + write!(f, "LE Secure Connections Confirmation Value") + } + CommonDataType::LeSecureConnectionsRandomValue => { + write!(f, "LE Secure Connections Random Value") + } + CommonDataType::LeSupportedFeatures => write!(f, "LE Supported Features"), + CommonDataType::Uri => write!(f, "URI"), + CommonDataType::IndoorPositioning => write!(f, "Indoor Positioning"), + CommonDataType::TransportDiscoveryData => { + write!(f, "Transport Discovery Data") + } + CommonDataType::ChannelMapUpdateIndication => { + write!(f, "Channel Map Update Indication") + } + CommonDataType::PbAdv => write!(f, "PB-ADV"), + CommonDataType::MeshMessage => write!(f, "Mesh Message"), + CommonDataType::MeshBeacon => write!(f, "Mesh Beacon"), + CommonDataType::BigInfo => write!(f, "BIGIInfo"), + CommonDataType::BroadcastCode => write!(f, "Broadcast Code"), + CommonDataType::ResolvableSetIdentifier => { + write!(f, "Resolvable Set Identifier") + } + CommonDataType::AdvertisingIntervalLong => { + write!(f, "Advertising Interval Long") + } + CommonDataType::ThreeDInformationData => write!(f, "3D Information Data"), + CommonDataType::ManufacturerSpecificData => { + write!(f, "Manufacturer Specific Data") + } + } + } +} + +/// Accumulates advertisement data to broadcast on a [crate::wrapper::device::Device]. +#[derive(Debug, Clone, Default)] +pub struct AdvertisementDataBuilder { + encoded_data: Vec, +} + +impl AdvertisementDataBuilder { + /// Returns a new, empty instance. + pub fn new() -> Self { + Self { + encoded_data: Vec::new(), + } + } + + /// Append advertising data to the builder. + /// + /// Returns an error if the data cannot be appended. + pub fn append( + &mut self, + type_code: impl Into, + data: &[u8], + ) -> Result<(), AdvertisementDataBuilderError> { + self.encoded_data.push( + data.len() + .try_into() + .ok() + .and_then(|len: u8| len.checked_add(1)) + .ok_or(AdvertisementDataBuilderError::DataTooLong)?, + ); + self.encoded_data.push(type_code.into().0); + self.encoded_data.extend_from_slice(data); + + Ok(()) + } + + pub(crate) fn into_bytes(self) -> Vec { + self.encoded_data + } +} + +/// Errors that can occur when building advertisement data with [AdvertisementDataBuilder]. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum AdvertisementDataBuilderError { + /// The provided adv data is too long to be encoded + #[error("Data too long")] + DataTooLong, +} + +#[derive(PartialEq, Eq, strum_macros::EnumIter)] +#[allow(missing_docs)] +/// Features in the Flags AD +pub enum Flags { + LeLimited, + LeDiscoverable, + NoBrEdr, + BrEdrController, + BrEdrHost, +} + +impl fmt::Debug for Flags { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Flags::LeLimited => write!(f, "LE Limited"), + Flags::LeDiscoverable => write!(f, "LE General"), + Flags::NoBrEdr => write!(f, "No BR/EDR"), + Flags::BrEdrController => write!(f, "BR/EDR C"), + Flags::BrEdrHost => write!(f, "BR/EDR H"), + } + } +} + +impl fmt::Display for Flags { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Flags::LeLimited => write!(f, "LE Limited Discoverable Mode"), + Flags::LeDiscoverable => write!(f, "LE General Discoverable Mode"), + Flags::NoBrEdr => write!(f, "BR/EDR Not Supported"), + Flags::BrEdrController => write!(f, "Simultaneous LE and BR/EDR (Controller)"), + Flags::BrEdrHost => write!(f, "Simultaneous LE and BR/EDR (Host)"), + } + } +} + +impl Flags { + /// Iterates over the flags that are present in the provided `flags` byte. + pub fn matching(flags: &[u8]) -> impl Iterator + '_ { + // The encoding is not clear from the spec: do we look at the first byte? or the last? + // In practice it's only one byte. + let first_byte = flags.first().unwrap_or(&0_u8); + + Self::iter().filter(move |f| { + let mask = match f { + Flags::LeLimited => 0x01_u8, + Flags::LeDiscoverable => 0x02, + Flags::NoBrEdr => 0x04, + Flags::BrEdrController => 0x08, + Flags::BrEdrHost => 0x10, + }; + + mask & first_byte > 0 + }) + } +} diff --git a/rust/src/wrapper/assigned_numbers/mod.rs b/rust/src/wrapper/assigned_numbers/mod.rs new file mode 100644 index 0000000..becdc11 --- /dev/null +++ b/rust/src/wrapper/assigned_numbers/mod.rs @@ -0,0 +1,53 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Assigned numbers from the Bluetooth spec. + +use crate::wrapper::core::Uuid16; +use lazy_static::lazy_static; +use pyo3::{ + intern, + types::{PyDict, PyModule}, + PyResult, Python, +}; +use std::collections; + +mod services; + +pub use services::SERVICE_IDS; + +lazy_static! { + /// Assigned company IDs + pub static ref COMPANY_IDS: collections::HashMap = load_company_ids() + .expect("Could not load company ids -- are Bumble's Python sources available?"); + +} + +fn load_company_ids() -> PyResult> { + // this takes about 4ms on a fast machine -- slower than constructing in rust, but not slow + // enough to worry about + Python::with_gil(|py| { + PyModule::import(py, intern!(py, "bumble.company_ids"))? + .getattr(intern!(py, "COMPANY_IDENTIFIERS"))? + .downcast::()? + .into_iter() + .map(|(k, v)| { + Ok(( + Uuid16::from_be_bytes(k.extract::()?.to_be_bytes()), + v.str()?.to_str()?.to_string(), + )) + }) + .collect::>>() + }) +} diff --git a/rust/src/wrapper/assigned_numbers/services.rs b/rust/src/wrapper/assigned_numbers/services.rs new file mode 100644 index 0000000..da1c992 --- /dev/null +++ b/rust/src/wrapper/assigned_numbers/services.rs @@ -0,0 +1,82 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Assigned service IDs + +use crate::wrapper::core::Uuid16; +use lazy_static::lazy_static; +use std::collections; + +lazy_static! { + /// Assigned service IDs + pub static ref SERVICE_IDS: collections::HashMap = [ + (0x1800_u16, "Generic Access"), + (0x1801, "Generic Attribute"), + (0x1802, "Immediate Alert"), + (0x1803, "Link Loss"), + (0x1804, "TX Power"), + (0x1805, "Current Time"), + (0x1806, "Reference Time Update"), + (0x1807, "Next DST Change"), + (0x1808, "Glucose"), + (0x1809, "Health Thermometer"), + (0x180A, "Device Information"), + (0x180D, "Heart Rate"), + (0x180E, "Phone Alert Status"), + (0x180F, "Battery"), + (0x1810, "Blood Pressure"), + (0x1811, "Alert Notification"), + (0x1812, "Human Interface Device"), + (0x1813, "Scan Parameters"), + (0x1814, "Running Speed and Cadence"), + (0x1815, "Automation IO"), + (0x1816, "Cycling Speed and Cadence"), + (0x1818, "Cycling Power"), + (0x1819, "Location and Navigation"), + (0x181A, "Environmental Sensing"), + (0x181B, "Body Composition"), + (0x181C, "User Data"), + (0x181D, "Weight Scale"), + (0x181E, "Bond Management"), + (0x181F, "Continuous Glucose Monitoring"), + (0x1820, "Internet Protocol Support"), + (0x1821, "Indoor Positioning"), + (0x1822, "Pulse Oximeter"), + (0x1823, "HTTP Proxy"), + (0x1824, "Transport Discovery"), + (0x1825, "Object Transfer"), + (0x1826, "Fitness Machine"), + (0x1827, "Mesh Provisioning"), + (0x1828, "Mesh Proxy"), + (0x1829, "Reconnection Configuration"), + (0x183A, "Insulin Delivery"), + (0x183B, "Binary Sensor"), + (0x183C, "Emergency Configuration"), + (0x183E, "Physical Activity Monitor"), + (0x1843, "Audio Input Control"), + (0x1844, "Volume Control"), + (0x1845, "Volume Offset Control"), + (0x1846, "Coordinated Set Identification Service"), + (0x1847, "Device Time"), + (0x1848, "Media Control Service"), + (0x1849, "Generic Media Control Service"), + (0x184A, "Constant Tone Extension"), + (0x184B, "Telephone Bearer Service"), + (0x184C, "Generic Telephone Bearer Service"), + (0x184D, "Microphone Control"), + ] + .into_iter() + .map(|(num, name)| (Uuid16::from_le_bytes(num.to_le_bytes()), name)) + .collect(); +} diff --git a/rust/src/wrapper/core.rs b/rust/src/wrapper/core.rs new file mode 100644 index 0000000..9871d4f --- /dev/null +++ b/rust/src/wrapper/core.rs @@ -0,0 +1,196 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Core types + +use crate::wrapper::adv::CommonDataTypeCode; +use lazy_static::lazy_static; +use nom::{bytes, combinator}; +use pyo3::{intern, PyObject, PyResult, Python}; +use std::fmt; + +lazy_static! { + static ref BASE_UUID: [u8; 16] = hex::decode("0000000000001000800000805F9B34FB") + .unwrap() + .try_into() + .unwrap(); +} + +/// A type code and data pair from an advertisement +pub type AdvertisementDataUnit = (CommonDataTypeCode, Vec); + +/// Contents of an advertisement +pub struct AdvertisingData(pub(crate) PyObject); + +impl AdvertisingData { + /// Data units in the advertisement contents + pub fn data_units(&self) -> PyResult> { + Python::with_gil(|py| { + let list = self.0.getattr(py, intern!(py, "ad_structures"))?; + + list.as_ref(py) + .iter()? + .collect::, _>>()? + .into_iter() + .map(|tuple| { + let type_code = tuple + .call_method1(intern!(py, "__getitem__"), (0,))? + .extract::()? + .into(); + let data = tuple + .call_method1(intern!(py, "__getitem__"), (1,))? + .extract::>()?; + Ok((type_code, data)) + }) + .collect::, _>>() + }) + } +} + +/// 16-bit UUID +#[derive(PartialEq, Eq, Hash)] +pub struct Uuid16 { + /// Big-endian bytes + uuid: [u8; 2], +} + +impl Uuid16 { + /// Construct a UUID from little-endian bytes + pub fn from_le_bytes(mut bytes: [u8; 2]) -> Self { + bytes.reverse(); + Self::from_be_bytes(bytes) + } + + /// Construct a UUID from big-endian bytes + pub fn from_be_bytes(bytes: [u8; 2]) -> Self { + Self { uuid: bytes } + } + + /// The UUID in big-endian bytes form + pub fn as_be_bytes(&self) -> [u8; 2] { + self.uuid + } + + /// The UUID in little-endian bytes form + pub fn as_le_bytes(&self) -> [u8; 2] { + let mut uuid = self.uuid; + uuid.reverse(); + uuid + } + + pub(crate) fn parse_le(input: &[u8]) -> nom::IResult<&[u8], Self> { + combinator::map_res(bytes::complete::take(2_usize), |b: &[u8]| { + b.try_into().map(|mut uuid: [u8; 2]| { + uuid.reverse(); + Self { uuid } + }) + })(input) + } +} + +impl fmt::Debug for Uuid16 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "UUID-16:{}", hex::encode_upper(self.uuid)) + } +} + +/// 32-bit UUID +#[derive(PartialEq, Eq, Hash)] +pub struct Uuid32 { + /// Big-endian bytes + uuid: [u8; 4], +} + +impl Uuid32 { + /// The UUID in big-endian bytes form + pub fn as_bytes(&self) -> [u8; 4] { + self.uuid + } + + pub(crate) fn parse(input: &[u8]) -> nom::IResult<&[u8], Self> { + combinator::map_res(bytes::complete::take(4_usize), |b: &[u8]| { + b.try_into().map(|mut uuid: [u8; 4]| { + uuid.reverse(); + Self { uuid } + }) + })(input) + } +} + +impl fmt::Debug for Uuid32 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "UUID-32:{}", hex::encode_upper(self.uuid)) + } +} + +impl From for Uuid32 { + fn from(value: Uuid16) -> Self { + let mut uuid = [0; 4]; + uuid[2..].copy_from_slice(&value.uuid); + Self { uuid } + } +} + +/// 128-bit UUID +#[derive(PartialEq, Eq, Hash)] +pub struct Uuid128 { + /// Big-endian bytes + uuid: [u8; 16], +} + +impl Uuid128 { + /// The UUID in big-endian bytes form + pub fn as_bytes(&self) -> [u8; 16] { + self.uuid + } + + pub(crate) fn parse_le(input: &[u8]) -> nom::IResult<&[u8], Self> { + combinator::map_res(bytes::complete::take(16_usize), |b: &[u8]| { + b.try_into().map(|mut uuid: [u8; 16]| { + uuid.reverse(); + Self { uuid } + }) + })(input) + } +} + +impl fmt::Debug for Uuid128 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}-{}-{}-{}-{}", + hex::encode_upper(&self.uuid[..4]), + hex::encode_upper(&self.uuid[4..6]), + hex::encode_upper(&self.uuid[6..8]), + hex::encode_upper(&self.uuid[8..10]), + hex::encode_upper(&self.uuid[10..]) + ) + } +} + +impl From for Uuid128 { + fn from(value: Uuid16) -> Self { + let mut uuid = *BASE_UUID; + uuid[2..4].copy_from_slice(&value.uuid); + Self { uuid } + } +} + +impl From for Uuid128 { + fn from(value: Uuid32) -> Self { + let mut uuid = *BASE_UUID; + uuid[..4].copy_from_slice(&value.uuid); + Self { uuid } + } +} diff --git a/rust/src/wrapper/device.rs b/rust/src/wrapper/device.rs new file mode 100644 index 0000000..c1f8bb7 --- /dev/null +++ b/rust/src/wrapper/device.rs @@ -0,0 +1,242 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Devices and connections to them + +use crate::wrapper::{ + adv::AdvertisementDataBuilder, + core::AdvertisingData, + gatt::Service, + gatt_client::ProfileServiceProxy, + hci::Address, + transport::{Sink, Source}, + ClosureCallback, +}; +use pyo3::types::PyDict; +use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject}; +use std::path; + +/// A device that can send/receive HCI frames. +#[derive(Clone)] +pub struct Device(PyObject); + +impl Device { + /// Create a Device per the provided file configured to communicate with a controller through an HCI source/sink + pub fn from_config_file_with_hci( + device_config: &path::Path, + source: Source, + sink: Sink, + ) -> PyResult { + Python::with_gil(|py| { + PyModule::import(py, intern!(py, "bumble.device"))? + .getattr(intern!(py, "Device"))? + .call_method1( + intern!(py, "from_config_file_with_hci"), + (device_config, source.0, sink.0), + ) + .map(|any| Self(any.into())) + }) + } + + /// Create a Device configured to communicate with a controller through an HCI source/sink + pub fn with_hci(name: &str, address: &str, source: Source, sink: Sink) -> PyResult { + Python::with_gil(|py| { + PyModule::import(py, intern!(py, "bumble.device"))? + .getattr(intern!(py, "Device"))? + .call_method1(intern!(py, "with_hci"), (name, address, source.0, sink.0)) + .map(|any| Self(any.into())) + }) + } + + /// Turn the device on + pub async fn power_on(&self) -> PyResult<()> { + Python::with_gil(|py| { + self.0 + .call_method0(py, intern!(py, "power_on")) + .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py))) + })? + .await + .map(|_| ()) + } + + /// Connect to a peer + pub async fn connect(&self, peer_addr: &str) -> PyResult { + Python::with_gil(|py| { + self.0 + .call_method1(py, intern!(py, "connect"), (peer_addr,)) + .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py))) + })? + .await + .map(Connection) + } + + /// Start scanning + pub async fn start_scanning(&self, filter_duplicates: bool) -> PyResult<()> { + Python::with_gil(|py| { + let kwargs = PyDict::new(py); + kwargs.set_item("filter_duplicates", filter_duplicates)?; + self.0 + .call_method(py, intern!(py, "start_scanning"), (), Some(kwargs)) + .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py))) + })? + .await + .map(|_| ()) + } + + /// Register a callback to be called for each advertisement + pub fn on_advertisement( + &mut self, + callback: impl Fn(Python, Advertisement) -> PyResult<()> + Send + 'static, + ) -> PyResult<()> { + let boxed = ClosureCallback::new(move |py, args, _kwargs| { + callback(py, Advertisement(args.get_item(0)?.into())) + }); + + Python::with_gil(|py| { + self.0 + .call_method1(py, intern!(py, "add_listener"), ("advertisement", boxed)) + }) + .map(|_| ()) + } + + /// Set the advertisement data to be used when [Device::start_advertising] is called. + pub fn set_advertisement(&mut self, adv_data: AdvertisementDataBuilder) -> PyResult<()> { + Python::with_gil(|py| { + self.0.setattr( + py, + intern!(py, "advertising_data"), + adv_data.into_bytes().as_slice(), + ) + }) + .map(|_| ()) + } + + /// Start advertising the data set with [Device.set_advertisement]. + pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> { + Python::with_gil(|py| { + let kwargs = PyDict::new(py); + kwargs.set_item("auto_restart", auto_restart)?; + + self.0 + .call_method(py, intern!(py, "start_advertising"), (), Some(kwargs)) + .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py))) + })? + .await + .map(|_| ()) + } + + /// Stop advertising. + pub async fn stop_advertising(&mut self) -> PyResult<()> { + Python::with_gil(|py| { + self.0 + .call_method0(py, intern!(py, "stop_advertising")) + .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py))) + })? + .await + .map(|_| ()) + } +} + +/// A connection to a remote device. +pub struct Connection(PyObject); + +/// The other end of a connection +pub struct Peer(PyObject); + +impl Peer { + /// Wrap a [Connection] in a Peer + pub fn new(conn: Connection) -> PyResult { + Python::with_gil(|py| { + PyModule::import(py, intern!(py, "bumble.device"))? + .getattr(intern!(py, "Peer"))? + .call1((conn.0,)) + .map(|obj| Self(obj.into())) + }) + } + + /// Populates the peer's cache of services. + pub async fn discover_services(&mut self) -> PyResult<()> { + Python::with_gil(|py| { + self.0 + .call_method0(py, intern!(py, "discover_services")) + .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py))) + })? + .await + .map(|_| ()) + } + + /// Returns a snapshot of the Services currently in the peer's cache + pub fn services(&self) -> PyResult> { + Python::with_gil(|py| { + let list = self.0.getattr(py, intern!(py, "services"))?; + + // there's probably a better way to do this + Ok(list + .as_ref(py) + .iter()? + .collect::, _>>()? + .into_iter() + .map(|any| Service(any.to_object(py))) + .collect::>()) + }) + } + + /// Build a [ProfileServiceProxy] for the specified type. + /// [Peer::discover_services] or some other means of populating the Peer's service cache must be + /// called first, or the required service won't be found. + pub fn create_service_proxy(&self) -> PyResult> { + Python::with_gil(|py| { + let module = py.import(P::PROXY_CLASS_MODULE)?; + let class = module.getattr(P::PROXY_CLASS_NAME)?; + self.0 + .call_method1(py, intern!(py, "create_service_proxy"), (class,)) + .map(|obj| { + if obj.is_none(py) { + None + } else { + Some(P::wrap(obj)) + } + }) + }) + } +} + +/// A BLE advertisement +pub struct Advertisement(PyObject); + +impl Advertisement { + /// Address that sent the advertisement + pub fn address(&self) -> PyResult
{ + Python::with_gil(|py| self.0.getattr(py, intern!(py, "address")).map(Address)) + } + + /// Returns true if the advertisement is connectable + pub fn is_connectable(&self) -> PyResult { + Python::with_gil(|py| { + self.0 + .getattr(py, intern!(py, "is_connectable"))? + .extract::(py) + }) + } + + /// RSSI of the advertisement + pub fn rssi(&self) -> PyResult { + Python::with_gil(|py| self.0.getattr(py, intern!(py, "rssi"))?.extract::(py)) + } + + /// Data in the advertisement + pub fn data(&self) -> PyResult { + Python::with_gil(|py| self.0.getattr(py, intern!(py, "data")).map(AdvertisingData)) + } +} diff --git a/rust/src/wrapper/gatt.rs b/rust/src/wrapper/gatt.rs new file mode 100644 index 0000000..e3fb2aa --- /dev/null +++ b/rust/src/wrapper/gatt.rs @@ -0,0 +1,67 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! GATT + +use crate::wrapper::ClosureCallback; +use pyo3::{intern, types::PyTuple, PyObject, PyResult, Python}; + +/// A GATT service +pub struct Service(pub(crate) PyObject); + +impl Service { + /// Discover the characteristics in this service. + /// + /// Populates an internal cache of characteristics in this service. + pub async fn discover_characteristics(&mut self) -> PyResult<()> { + Python::with_gil(|py| { + self.0 + .call_method0(py, intern!(py, "discover_characteristics")) + .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py))) + })? + .await + .map(|_| ()) + } +} + +/// A GATT characteristic +pub struct Characteristic(pub(crate) PyObject); + +impl Characteristic { + /// Subscribe to changes to the characteristic, executing `callback` for each new value + pub async fn subscribe( + &mut self, + callback: impl Fn(Python, &PyTuple) -> PyResult<()> + Send + 'static, + ) -> PyResult<()> { + let boxed = ClosureCallback::new(move |py, args, _kwargs| callback(py, args)); + + Python::with_gil(|py| { + self.0 + .call_method1(py, intern!(py, "subscribe"), (boxed,)) + .and_then(|obj| pyo3_asyncio::tokio::into_future(obj.as_ref(py))) + })? + .await + .map(|_| ()) + } + + /// Read the current value of the characteristic + pub async fn read_value(&self) -> PyResult { + Python::with_gil(|py| { + self.0 + .call_method0(py, intern!(py, "read_value")) + .and_then(|obj| pyo3_asyncio::tokio::into_future(obj.as_ref(py))) + })? + .await + } +} diff --git a/rust/src/wrapper/gatt_client.rs b/rust/src/wrapper/gatt_client.rs new file mode 100644 index 0000000..a7e269a --- /dev/null +++ b/rust/src/wrapper/gatt_client.rs @@ -0,0 +1,28 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! GATT client support + +use pyo3::PyObject; + +/// Equivalent to the Python `ProfileServiceProxy`. +pub trait ProfileServiceProxy { + /// The module containing the proxy class + const PROXY_CLASS_MODULE: &'static str; + /// The module class name + const PROXY_CLASS_NAME: &'static str; + + /// Wrap a PyObject in the Rust wrapper type + fn wrap(obj: PyObject) -> Self; +} diff --git a/rust/src/wrapper/hci.rs b/rust/src/wrapper/hci.rs new file mode 100644 index 0000000..48f7dc1 --- /dev/null +++ b/rust/src/wrapper/hci.rs @@ -0,0 +1,112 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! HCI + +use itertools::Itertools as _; +use pyo3::{exceptions::PyException, intern, types::PyModule, PyErr, PyObject, PyResult, Python}; + +/// A Bluetooth address +pub struct Address(pub(crate) PyObject); + +impl Address { + /// The type of address + pub fn address_type(&self) -> PyResult { + Python::with_gil(|py| { + let addr_type = self + .0 + .getattr(py, intern!(py, "address_type"))? + .extract::(py)?; + + let module = PyModule::import(py, intern!(py, "bumble.hci"))?; + let klass = module.getattr(intern!(py, "Address"))?; + + if addr_type + == klass + .getattr(intern!(py, "PUBLIC_DEVICE_ADDRESS"))? + .extract::()? + { + Ok(AddressType::PublicDevice) + } else if addr_type + == klass + .getattr(intern!(py, "RANDOM_DEVICE_ADDRESS"))? + .extract::()? + { + Ok(AddressType::RandomDevice) + } else if addr_type + == klass + .getattr(intern!(py, "PUBLIC_IDENTITY_ADDRESS"))? + .extract::()? + { + Ok(AddressType::PublicIdentity) + } else if addr_type + == klass + .getattr(intern!(py, "RANDOM_IDENTITY_ADDRESS"))? + .extract::()? + { + Ok(AddressType::RandomIdentity) + } else { + Err(PyErr::new::("Invalid address type")) + } + }) + } + + /// True if the address is static + pub fn is_static(&self) -> PyResult { + Python::with_gil(|py| { + self.0 + .getattr(py, intern!(py, "is_static"))? + .extract::(py) + }) + } + + /// True if the address is resolvable + pub fn is_resolvable(&self) -> PyResult { + Python::with_gil(|py| { + self.0 + .getattr(py, intern!(py, "is_resolvable"))? + .extract::(py) + }) + } + + /// Address bytes in _little-endian_ format + pub fn as_le_bytes(&self) -> PyResult> { + Python::with_gil(|py| { + self.0 + .call_method0(py, intern!(py, "to_bytes"))? + .extract::>(py) + }) + } + + /// Address bytes as big-endian colon-separated hex + pub fn as_hex(&self) -> PyResult { + self.as_le_bytes().map(|bytes| { + bytes + .into_iter() + .rev() + .map(|byte| hex::encode_upper([byte])) + .join(":") + }) + } +} + +/// BT address types +#[allow(missing_docs)] +#[derive(PartialEq, Eq, Debug)] +pub enum AddressType { + PublicDevice, + RandomDevice, + PublicIdentity, + RandomIdentity, +} diff --git a/rust/src/wrapper/logging.rs b/rust/src/wrapper/logging.rs new file mode 100644 index 0000000..141cc04 --- /dev/null +++ b/rust/src/wrapper/logging.rs @@ -0,0 +1,27 @@ +//! Bumble & Python logging + +use pyo3::types::PyDict; +use pyo3::{intern, types::PyModule, PyResult, Python}; +use std::env; + +/// Returns the uppercased contents of the `BUMBLE_LOGLEVEL` env var, or `default` if it is not present or not UTF-8. +/// +/// The result could be passed to [py_logging_basic_config] to configure Python's logging +/// accordingly. +pub fn bumble_env_logging_level(default: impl Into) -> String { + env::var("BUMBLE_LOGLEVEL") + .unwrap_or_else(|_| default.into()) + .to_ascii_uppercase() +} + +/// Call `logging.basicConfig` with the provided logging level +pub fn py_logging_basic_config(log_level: impl Into) -> PyResult<()> { + Python::with_gil(|py| { + let kwargs = PyDict::new(py); + kwargs.set_item("level", log_level.into())?; + + PyModule::import(py, intern!(py, "logging"))? + .call_method(intern!(py, "basicConfig"), (), Some(kwargs)) + .map(|_| ()) + }) +} diff --git a/rust/src/wrapper/mod.rs b/rust/src/wrapper/mod.rs new file mode 100644 index 0000000..361eb4a --- /dev/null +++ b/rust/src/wrapper/mod.rs @@ -0,0 +1,94 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Types that wrap the Python API. +//! +//! Because mutability, aliasing, etc is all hidden behind Python, the normal Rust rules about +//! only one mutable reference to one piece of memory, etc, may not hold since using `&mut self` +//! instead of `&self` is only guided by inspection of the Python source, not the compiler. +//! +//! The modules are generally structured to mirror the Python equivalents. + +// Re-exported to make it easy for users to depend on the same `PyObject`, etc +pub use pyo3; +use pyo3::{ + prelude::*, + types::{PyDict, PyTuple}, +}; +pub use pyo3_asyncio; + +pub mod adv; +pub mod assigned_numbers; +pub mod core; +pub mod device; +pub mod gatt; +pub mod gatt_client; +pub mod hci; +pub mod logging; +pub mod profile; +pub mod transport; + +/// Convenience extensions to [PyObject] +pub trait PyObjectExt { + /// Get a GIL-bound reference + fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny; + + /// Extract any [FromPyObject] implementation from this value + fn extract_with_gil(&self) -> PyResult + where + T: for<'a> FromPyObject<'a>, + { + Python::with_gil(|py| self.gil_ref(py).extract::()) + } +} + +impl PyObjectExt for PyObject { + fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny { + self.as_ref(py) + } +} + +/// Wrapper to make Rust closures ([Fn] implementations) callable from Python. +/// +/// The Python callable form returns a Python `None`. +#[pyclass(name = "SubscribeCallback")] +pub(crate) struct ClosureCallback { + // can't use generics in a pyclass, so have to box + #[allow(clippy::type_complexity)] + callback: Box) -> PyResult<()> + Send + 'static>, +} + +impl ClosureCallback { + /// Create a new callback around the provided closure + pub fn new( + callback: impl Fn(Python, &PyTuple, Option<&PyDict>) -> PyResult<()> + Send + 'static, + ) -> Self { + Self { + callback: Box::new(callback), + } + } +} + +#[pymethods] +impl ClosureCallback { + #[pyo3(signature = (*args, **kwargs))] + fn __call__( + &self, + py: Python<'_>, + args: &PyTuple, + kwargs: Option<&PyDict>, + ) -> PyResult> { + (self.callback)(py, args, kwargs).map(|_| py.None()) + } +} diff --git a/rust/src/wrapper/profile.rs b/rust/src/wrapper/profile.rs new file mode 100644 index 0000000..0243848 --- /dev/null +++ b/rust/src/wrapper/profile.rs @@ -0,0 +1,47 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! GATT profiles + +use crate::wrapper::{gatt::Characteristic, gatt_client::ProfileServiceProxy}; +use pyo3::{intern, PyObject, PyResult, Python}; + +/// Exposes the battery GATT service +pub struct BatteryService(PyObject); + +impl BatteryService { + /// Get the battery level, if available + pub fn battery_level(&self) -> PyResult> { + Python::with_gil(|py| { + self.0 + .getattr(py, intern!(py, "battery_level")) + .map(|level| { + if level.is_none(py) { + None + } else { + Some(Characteristic(level)) + } + }) + }) + } +} + +impl ProfileServiceProxy for BatteryService { + const PROXY_CLASS_MODULE: &'static str = "bumble.profiles.battery_service"; + const PROXY_CLASS_NAME: &'static str = "BatteryServiceProxy"; + + fn wrap(obj: PyObject) -> Self { + Self(obj) + } +} diff --git a/rust/src/wrapper/transport.rs b/rust/src/wrapper/transport.rs new file mode 100644 index 0000000..6c9468d --- /dev/null +++ b/rust/src/wrapper/transport.rs @@ -0,0 +1,72 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! HCI packet transport + +use pyo3::{intern, types::PyModule, PyObject, PyResult, Python}; + +/// A source/sink pair for HCI packet I/O. +/// +/// See . +pub struct Transport(PyObject); + +impl Transport { + /// Open a new Transport for the provided spec, e.g. `"usb:0"` or `"android-netsim"`. + pub async fn open(transport_spec: impl Into) -> PyResult { + Python::with_gil(|py| { + PyModule::import(py, intern!(py, "bumble.transport"))? + .call_method1(intern!(py, "open_transport"), (transport_spec.into(),)) + .and_then(pyo3_asyncio::tokio::into_future) + })? + .await + .map(Self) + } + + /// Close the transport. + pub async fn close(&mut self) -> PyResult<()> { + Python::with_gil(|py| { + self.0 + .call_method0(py, intern!(py, "close")) + .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py))) + })? + .await + .map(|_| ()) + } + + /// Returns the source half of the transport. + pub fn source(&self) -> PyResult { + Python::with_gil(|py| self.0.getattr(py, intern!(py, "source"))).map(Source) + } + + /// Returns the sink half of the transport. + pub fn sink(&self) -> PyResult { + Python::with_gil(|py| self.0.getattr(py, intern!(py, "sink"))).map(Sink) + } +} + +impl Drop for Transport { + fn drop(&mut self) { + // can't await in a Drop impl, but we can at least spawn a task to do it + let obj = self.0.clone(); + tokio::spawn(async move { Self(obj).close().await }); + } +} + +/// The source side of a [Transport]. +#[derive(Clone)] +pub struct Source(pub(crate) PyObject); + +/// The sink side of a [Transport]. +#[derive(Clone)] +pub struct Sink(pub(crate) PyObject);