commit 6675986579b0576e8cb2a1e72ff0ef5e7d3072a4 Author: CGH0S7 <776459475@qq.com> Date: Sun Jan 4 16:50:19 2026 +0800 first commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..af0c44c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: Rust + +on: + push: + branches: + - main + - dev + - feature/ci + pull_request: + branches: + - main + - dev + - feature/ci + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + name: Clippy + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install dependencies + run: | + sudo apt-get -y update + sudo apt-get -y install libdbus-1-dev protobuf-compiler + + - name: Install rust + run: | + rustup update stable && rustup default stable + rustup component add clippy + + - name: Run clippy + run: cargo clippy --all --all-features -- -Dwarnings + + test: + name: Test + runs-on: ubuntu-latest + + strategy: + matrix: + toolchain: [stable, nightly] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install dependencies + run: | + sudo apt-get -y update + sudo apt-get -y install libdbus-1-dev protobuf-compiler + + - name: Install rust + run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} + + - name: Build + run: | + cargo build --all --all-features + cargo build --all --all-features --examples + + - name: Test + run: | + cargo test --all --all-features + cargo build --all --all-features --examples diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e0ed6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.vscode +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6fa9b5c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2392 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum 0.26.3", + "tokio", + "tokio-stream", + "uuid", +] + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f5378ea264ad4f82bbc826628b5aad714a75abf6ece087e923010eb937fb6" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.100", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.100", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "dbus" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "winapi", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4c83437187544ba5142427746835061b330446ca8902eabd70e4afb8f76de0" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.100", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gfps" +version = "0.1.3" +dependencies = [ + "bluer", + "bytes", + "futures", + "num_enum", + "pretty-hex", + "smallvec", + "tokio", + "tokio-util", + "uuid", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.17", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libdbus-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lru" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + +[[package]] +name = "maestro" +version = "0.1.5" +dependencies = [ + "anyhow", + "arrayvec", + "bluer", + "bytes", + "futures", + "num_enum", + "pretty-hex", + "prost", + "prost-build", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +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.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "multimap" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[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 = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbpctrl" +version = "0.1.8" +dependencies = [ + "anyhow", + "bluer", + "clap", + "clap_complete", + "futures", + "maestro", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "pbpctui" +version = "0.1.5" +dependencies = [ + "anyhow", + "bluer", + "crossterm", + "futures", + "maestro", + "ratatui", + "regex", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "pretty-hex" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc83ee4a840062f368f9096d80077a9841ec117e17e7f700df81958f1451254" + +[[package]] +name = "prettyplease" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6" +dependencies = [ + "proc-macro2", + "syn 2.0.100", +] + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.100", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru", + "strum 0.27.2", + "thiserror 2.0.17", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum 0.27.2", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ee1aca2bc74ef9589efa7ccaa0f3752751399940356209b3fd80c078149b5e" +dependencies = [ + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "socket2" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[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.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.100", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[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.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "tempfile" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.10.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "tokio" +version = "1.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "atomic", + "getrandom", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.10.0", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..76de6fd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +resolver = "2" + +members = [ + "cli", + "libgfps", + "libmaestro", + "tui", +] diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..3981e21 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Maximilian Luz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..403b7e7 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# `pbpctrl` + +Control Google Pixel Buds Pro from the Linux command line. Might or might not work on other Pixel Buds devices. + +Allows reading of battery, hardware, software, and runtime information as well as reading and changing settings (ANC state, equalizer, ...). + + +## Installation + +### Arch Linux + +A [`pbpctrl`](https://aur.archlinux.org/packages/pbpctrl) package is provided via the AUR. +Alternatively, the [`pbpctrl-git`](https://aur.archlinux.org/packages/pbpctrl-git) package can be used to directly build from the latest state on the `main` branch. + +### Debian/Ubuntu + +A [`pbpctrl`](https://mpr.makedeb.org/packages/pbpctrl) package is provided via the MPR. + +### Installation via `cargo` + +You will need to install the following dependencies: + +- Ubuntu: `libdbus-1-dev pkg-config protobuf-compiler` +- Arch Linux: Please refer to the dependencies (`depends` and `makedepends` fields) in [this PKGBUILD](https://aur.archlinux.org/cgit/aur.git/tree/PKGBUILD?h=pbpctrl). + +To build install the binary via cargo, run +```sh +cargo install pbpctrl --git https://github.com/qzed/pbpctrl/ +``` +Use the `--tag` option if you want to install a specific tag instead of the latest `main` branch. + + +## TUI Extension + +A TUI wrapper `pbpctui` is provided in the `tui` directory. It uses `ratatui` to provide a visual interface for monitoring status and changing settings. + +### Installation + +To install the TUI from the source: + +```sh +cargo install pbpctui --git https://github.com/qzed/pbpctrl/ +``` + +### Usage + +Ensure `pbpctrl` is installed and available in your PATH (or in the current directory). +Run: +```sh +pbpctui +``` +Navigation: +- **Tab**: Switch between Status and Settings tabs. +- **Arrow Keys or j/k**: Navigate settings. +- **Enter**: Toggle/Cycle options. +- **Left/Right**: Adjust slider values (Balance, EQ). +- **q**: Quit. + + +## Instructions + +Pair and connect your Pixel Buds Pro before use. +Run `pbpctrl help` for more information. + + +## Notes on Battery Information + +The Pixel Buds Pro support basic battery information via the AVCPR standard. +Support for this is still experimental in BlueZ and needs to be enabled manually by editing `/etc/bluetooth/main.conf` and setting +``` +[General] +Experimental = true +``` +or by starting BlueZ with the `--experimental` flag. +After this, battery status should be provided via UPower. + +Note that this, however, will only provide a single battery meter for both buds combined, and none for the case. +For more detailed information, use `pbpctrl show battery`. +This also allows reading of the case battery as long as one bud is placed in the case (note that the case does not have a Bluetooth receiver itself). + + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0) +- MIT License ([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..35a784a --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pbpctrl" +authors = ["Maximilian Luz "] +version = "0.1.8" +edition = "2024" +license = "MIT/Apache-2.0" +description = "Command-line utility for controlling Google Pixel Buds Pro" +repository = "https://github.com/qzed/pbpctrl" + +[dependencies] +anyhow = "1.0.98" +bluer = { version = "0.17.4", features = ["bluetoothd", "rfcomm"] } +clap = { version = "4.5.37", features = ["derive"] } +futures = "0.3.31" +maestro = { path = "../libmaestro" } +tokio = { version = "1.44.2", features = ["rt", "macros", "signal"] } +tracing = "0.1.41" +tracing-subscriber = "0.3.19" + +[build-dependencies] +bluer = { version = "0.17.4" } +clap = { version = "4.5.37", features = ["derive"] } +clap_complete = "4.5.47" +maestro = { path = "../libmaestro" } diff --git a/cli/build.rs b/cli/build.rs new file mode 100644 index 0000000..5f8b753 --- /dev/null +++ b/cli/build.rs @@ -0,0 +1,21 @@ +use std::env; +use clap::CommandFactory; +use clap_complete::shells; + +#[allow(dead_code)] +#[path = "src/cli.rs"] +mod cli; +use cli::*; + + +fn main() { + let outdir = env::var_os("CARGO_TARGET_DIR") + .or_else(|| env::var_os("OUT_DIR")) + .unwrap(); + + let mut cmd = Args::command(); + + clap_complete::generate_to(shells::Bash, &mut cmd, "pbpctrl", &outdir).unwrap(); + clap_complete::generate_to(shells::Zsh, &mut cmd, "pbpctrl", &outdir).unwrap(); + clap_complete::generate_to(shells::Fish, &mut cmd, "pbpctrl", &outdir).unwrap(); +} diff --git a/cli/src/bt.rs b/cli/src/bt.rs new file mode 100644 index 0000000..f9aa8d9 --- /dev/null +++ b/cli/src/bt.rs @@ -0,0 +1,90 @@ +use std::time::Duration; + +use anyhow::Result; + +use bluer::{Adapter, Address, Device, Session}; +use bluer::rfcomm::{ProfileHandle, Role, ReqError, Stream, Profile}; + +use futures::StreamExt; + + +const PIXEL_BUDS_CLASS: u32 = 0x240404; +const PIXEL_BUDS2_CLASS: u32 = 0x244404; + + +pub async fn find_maestro_device(adapter: &Adapter) -> Result { + for addr in adapter.device_addresses().await? { + let dev = adapter.device(addr)?; + + let class = dev.class().await?.unwrap_or(0); + if class != PIXEL_BUDS_CLASS && class != PIXEL_BUDS2_CLASS { + continue; + } + + let uuids = dev.uuids().await?.unwrap_or_default(); + if !uuids.contains(&maestro::UUID) { + continue; + } + + tracing::debug!(address=%addr, "found compatible device"); + return Ok(dev); + } + + tracing::debug!("no compatible device found"); + anyhow::bail!("no compatible device found") +} + +pub async fn connect_maestro_rfcomm(session: &Session, dev: &Device) -> Result { + let maestro_profile = Profile { + uuid: maestro::UUID, + role: Some(Role::Client), + require_authentication: Some(false), + require_authorization: Some(false), + auto_connect: Some(false), + ..Default::default() + }; + + tracing::debug!("registering maestro profile"); + let mut handle = session.register_profile(maestro_profile).await?; + + tracing::debug!("connecting to maestro profile"); + let stream = tokio::try_join!( + try_connect_profile(dev), + handle_requests_for_profile(&mut handle, dev.address()), + )?.1; + + Ok(stream) +} + +async fn try_connect_profile(dev: &Device) -> Result<()> { + const RETRY_TIMEOUT: Duration = Duration::from_secs(1); + const MAX_TRIES: u32 = 3; + + let mut i = 0; + while let Err(err) = dev.connect_profile(&maestro::UUID).await { + if i >= MAX_TRIES { return Err(err.into()) } + i += 1; + + tracing::warn!(error=?err, "connecting to profile failed, trying again ({}/{})", i, MAX_TRIES); + + tokio::time::sleep(RETRY_TIMEOUT).await; + } + + tracing::debug!(address=%dev.address(), "maestro profile connected"); + Ok(()) +} + +async fn handle_requests_for_profile(handle: &mut ProfileHandle, address: Address) -> Result { + while let Some(req) = handle.next().await { + tracing::debug!(address=%req.device(), "received new profile connection request"); + + if req.device() == address { + tracing::debug!(address=%req.device(), "accepting profile connection request"); + return Ok(req.accept()?); + } else { + req.reject(ReqError::Rejected); + } + } + + anyhow::bail!("profile terminated without requests") +} diff --git a/cli/src/cli.rs b/cli/src/cli.rs new file mode 100644 index 0000000..53628ed --- /dev/null +++ b/cli/src/cli.rs @@ -0,0 +1,317 @@ +use bluer::Address; +use clap::{Parser, Subcommand, ValueEnum}; + +use maestro::service::settings; + + +/// Control Google Pixel Buds Pro from the command line +#[derive(Debug, Parser)] +#[command(author, version, about, long_about = None)] +pub struct Args { + /// Device to use (search for compatible device if unspecified) + #[arg(short, long, global=true)] + pub device: Option
, + + #[command(subcommand)] + pub command: Command +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Show device information + Show { + #[command(subcommand)] + command: ShowCommand + }, + + /// Read settings value + Get { + #[command(subcommand)] + setting: GetSetting + }, + + /// Write settings value + Set { + #[command(subcommand)] + setting: SetSetting + }, +} + +#[derive(Debug, Subcommand)] +pub enum ShowCommand { + /// Show software information. + Software, + + /// Show hardware information. + Hardware, + + /// Show runtime information. + Runtime, + + /// Show battery status. + Battery, +} + +#[derive(Debug, Subcommand)] +pub enum GetSetting { + /// Get automatic over-the-air update status + AutoOta, + + /// Get on-head-detection state (enabled/disabled) + Ohd, + + /// Get the flag indicating whether the out-of-box experience phase is + /// finished + OobeIsFinished, + + /// Get gesture state (enabled/disabled) + Gestures, + + /// Get diagnostics state (enabled/disabled) + Diagnostics, + + /// Get out-of-box-experience mode state (enabled/disabled) + OobeMode, + + /// Get hold-gesture action + GestureControl, + + /// Get multipoint audio state (enabled/disabled) + Multipoint, + + /// Get adaptive noise-cancelling gesture loop + AncGestureLoop, + + /// Get adaptive noise-cancelling state + Anc, + + /// Get volume-dependent EQ state (enabled/disabled) + VolumeEq, + + /// Get 5-band EQ + Eq, + + /// Get volume balance + Balance, + + /// Get mono output state + Mono, + + /// Get volume exposure notifications state (enabled/disabled) + VolumeExposureNotifications, + + /// Get automatic transparency mode state (enabled/disabled) + SpeechDetection, +} + +#[derive(Debug, Subcommand)] +pub enum SetSetting { + /// Enable/disable automatic over-the-air updates + /// + /// Note: Updates are initiated by the Google Buds app on your phone. This + /// flag controls whether updates can be done automatically when the device + /// is not in use. + AutoOta { + /// Whether to enable or disable automatic over-the-air (OTA) updates + #[arg(action=clap::ArgAction::Set)] + value: bool, + }, + + /// Enable/disable on-head detection + Ohd { + /// Whether to enable or disable on-head detection + #[arg(action=clap::ArgAction::Set)] + value: bool, + }, + + /// Set the flag indicating whether the out-of-box experience phase is + /// finished + /// + /// Note: You normally do not want to change this flag. It is used to + /// indicate whether the out-of-box experience (OOBE) phase has been + /// concluded, i.e., the setup wizard has been run and the device has been + /// set up. + OobeIsFinished { + /// Whether the OOBE setup has been finished + #[arg(action=clap::ArgAction::Set)] + value: bool, + }, + + /// Enable/disable gestures + Gestures { + /// Whether to enable or disable gestures + #[arg(action=clap::ArgAction::Set)] + value: bool, + }, + + /// Enable/disable diagnostics + /// + /// Note: This will also cause the Google Buds app on your phone to send + /// diagnostics data to Google. + Diagnostics { + /// Whether to enable or disable diagnostics + #[arg(action=clap::ArgAction::Set)] + value: bool, + }, + + /// Enable/disable out-of-box-experience mode + /// + /// Note: You normally do not want to enable this mode. It is used to + /// intercept and block touch gestures during the setup wizard. + OobeMode { + /// Whether to enable or disable the out-of-box experience mode + #[arg(action=clap::ArgAction::Set)] + value: bool, + }, + + /// Set hold-gesture action + GestureControl { + /// Left gesture action + #[arg(value_enum)] + left: HoldGestureAction, + + /// Right gesture action + #[arg(value_enum)] + right: HoldGestureAction, + }, + + /// Enable/disable multipoint audio + Multipoint { + /// Whether to enable or disable multipoint audio + #[arg(action=clap::ArgAction::Set)] + value: bool, + }, + + /// Set adaptive noise-cancelling gesture loop + AncGestureLoop { + /// Enable 'off' mode in loop + #[arg(action=clap::ArgAction::Set)] + off: bool, + + /// Enable 'active' mode in loop + #[arg(action=clap::ArgAction::Set)] + active: bool, + + /// Enable 'aware' mode in loop + #[arg(action=clap::ArgAction::Set)] + aware: bool, + + /// Enable 'adaptive' mode in loop + #[arg(action=clap::ArgAction::Set)] + adaptive: bool, + }, + + /// Set adaptive noise-cancelling state + Anc { + /// New ANC state or action to change state + #[arg(value_enum)] + value: AncState, + }, + + /// Enable/disable volume-dependent EQ + VolumeEq { + /// Whether to enable or disable volume-dependent EQ + #[arg(action=clap::ArgAction::Set)] + value: bool, + }, + + /// Set 5-band EQ + Eq { + /// Low-bass band (min: -6.0, max: 6.0) + #[arg(value_parser=parse_eq_value)] + low_bass: f32, + + /// Bass band (min: -6.0, max: 6.0) + #[arg(value_parser=parse_eq_value)] + bass: f32, + + /// Mid band (min: -6.0, max: 6.0) + #[arg(value_parser=parse_eq_value)] + mid: f32, + + /// Treble band (min: -6.0, max: 6.0) + #[arg(value_parser=parse_eq_value)] + treble: f32, + + /// Upper treble band (min: -6.0, max: 6.0) + #[arg(value_parser=parse_eq_value)] + upper_treble: f32, + }, + + /// Set volume balance + Balance { + /// Volume balance from -100 (left) to +100 (right) + #[arg(value_parser=parse_balance)] + value: i32, + }, + + /// Set mono output + Mono { + /// Whether to force mono output + #[arg(action=clap::ArgAction::Set)] + value: bool, + }, + + /// Enable/disable volume level exposure notifications + VolumeExposureNotifications { + /// Whether to enable or disable volume level exposure notifications + #[arg(action=clap::ArgAction::Set)] + value: bool, + }, + + /// Enable/disable automatic transparency mode via speech detection + SpeechDetection { + /// Whether to enable or disable the automatic transparency mode via speech detection + #[arg(action=clap::ArgAction::Set)] + value: bool, + }, +} + +#[derive(Debug, ValueEnum, Clone, Copy)] +pub enum AncState { + Off, + Active, + Aware, + Adaptive, + CycleNext, + CyclePrev, +} + +#[derive(Debug, ValueEnum, Clone, Copy)] +pub enum HoldGestureAction { + Anc, + Assistant, +} + +impl From for settings::RegularActionTarget { + fn from(value: HoldGestureAction) -> Self { + match value { + HoldGestureAction::Anc => settings::RegularActionTarget::AncControl, + HoldGestureAction::Assistant => settings::RegularActionTarget::AssistantQuery, + } + } +} + +fn parse_eq_value(s: &str) -> std::result::Result { + let val = s.parse().map_err(|e| format!("{e}"))?; + + if val > settings::EqBands::MAX_VALUE { + Err(format!("exceeds maximum of {}", settings::EqBands::MAX_VALUE)) + } else if val < settings::EqBands::MIN_VALUE { + Err(format!("exceeds minimum of {}", settings::EqBands::MIN_VALUE)) + } else { + Ok(val) + } +} + +fn parse_balance(s: &str) -> std::result::Result { + let val = s.parse().map_err(|e| format!("{e}"))?; + + if val > 100 { + Err("exceeds maximum of 100".to_string()) + } else if val < -100 { + Err("exceeds minimum of -100".to_string()) + } else { + Ok(val) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..042c0fb --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,506 @@ +mod bt; +mod cli; + +use anyhow::Result; +use clap::{Parser, CommandFactory}; +use futures::{Future, StreamExt}; + +use maestro::protocol::{utils, addr}; +use maestro::pwrpc::client::{Client, ClientHandle}; +use maestro::protocol::codec::Codec; +use maestro::service::MaestroService; +use maestro::service::settings::{self, Setting, SettingValue}; + +use cli::*; + + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + + // set up session + let session = bluer::Session::new().await?; + let adapter = session.default_adapter().await?; + + // set up device + let dev = if let Some(address) = args.device { + tracing::debug!("using provided address: {}", address); + adapter.device(address)? + } else { + tracing::debug!("no device specified, searching for compatible one"); + bt::find_maestro_device(&adapter).await? + }; + + // set up profile + let stream = bt::connect_maestro_rfcomm(&session, &dev).await?; + + // set up codec + let codec = Codec::new(); + let stream = codec.wrap(stream); + + // set up RPC client + let mut client = Client::new(stream); + let handle = client.handle(); + + // resolve channel + let channel = utils::resolve_channel(&mut client).await?; + + match args.command { + Command::Show { command } => match command { + ShowCommand::Software => run(client, cmd_show_software(handle, channel)).await, + ShowCommand::Hardware => run(client, cmd_show_hardware(handle, channel)).await, + ShowCommand::Runtime => run(client, cmd_show_runtime(handle, channel)).await, + ShowCommand::Battery => run(client, cmd_show_battery(handle, channel)).await, + }, + Command::Get { setting } => match setting { + GetSetting::AutoOta => { + run(client, cmd_get_setting(handle, channel, settings::id::AutoOtaEnable)).await + }, + GetSetting::Ohd => { + run(client, cmd_get_setting(handle, channel, settings::id::OhdEnable)).await + }, + GetSetting::OobeIsFinished => { + run(client, cmd_get_setting(handle, channel, settings::id::OobeIsFinished)).await + }, + GetSetting::Gestures => { + run(client, cmd_get_setting(handle, channel, settings::id::GestureEnable)).await + }, + GetSetting::Diagnostics => { + run(client, cmd_get_setting(handle, channel, settings::id::DiagnosticsEnable)).await + } + GetSetting::OobeMode => { + run(client, cmd_get_setting(handle, channel, settings::id::OobeMode)).await + }, + GetSetting::GestureControl => { + run(client, cmd_get_setting(handle, channel, settings::id::GestureControl)).await + }, + GetSetting::Multipoint => { + run(client, cmd_get_setting(handle, channel, settings::id::MultipointEnable)).await + }, + GetSetting::AncGestureLoop => { + run(client, cmd_get_setting(handle, channel, settings::id::AncrGestureLoop)).await + } + GetSetting::Anc => { + run(client, cmd_get_setting(handle, channel, settings::id::CurrentAncrState)).await + }, + GetSetting::VolumeEq => { + run(client, cmd_get_setting(handle, channel, settings::id::VolumeEqEnable)).await + }, + GetSetting::Eq => { + run(client, cmd_get_setting(handle, channel, settings::id::CurrentUserEq)).await + }, + GetSetting::Balance => { + run(client, cmd_get_setting(handle, channel, settings::id::VolumeAsymmetry)).await + }, + GetSetting::Mono => { + run(client, cmd_get_setting(handle, channel, settings::id::SumToMono)).await + }, + GetSetting::VolumeExposureNotifications => { + run(client, cmd_get_setting(handle, channel, settings::id::VolumeExposureNotifications)).await + }, + GetSetting::SpeechDetection => { + run(client, cmd_get_setting(handle, channel, settings::id::SpeechDetection)).await + }, + }, + Command::Set { setting } => match setting { + SetSetting::AutoOta { value } => { + let value = SettingValue::AutoOtaEnable(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::Ohd { value } => { + let value = SettingValue::OhdEnable(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::OobeIsFinished { value } => { + let value = SettingValue::OobeIsFinished(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::Gestures { value } => { + let value = SettingValue::GestureEnable(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::Diagnostics { value } => { + let value = SettingValue::DiagnosticsEnable(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::OobeMode { value } => { + let value = SettingValue::OobeMode(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::GestureControl { left, right } => { + let value = settings::GestureControl { left: left.into(), right: right.into() }; + let value = SettingValue::GestureControl(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::Multipoint { value } => { + let value = SettingValue::MultipointEnable(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::AncGestureLoop { off, active, aware, adaptive } => { + let value = settings::AncrGestureLoop { off, active, aware, adaptive }; + + if !value.is_valid() { + use clap::error::ErrorKind; + + let mut cmd = Args::command(); + let err = cmd.error( + ErrorKind::InvalidValue, + "This command requires at least tow enabled ('true') modes" + ); + err.exit(); + } + + let value = SettingValue::AncrGestureLoop(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::Anc { value } => { + match value { + AncState::Off => { + let value = SettingValue::CurrentAncrState(settings::AncState::Off); + run(client, cmd_set_setting(handle, channel, value)).await + }, + AncState::Aware => { + let value = SettingValue::CurrentAncrState(settings::AncState::Aware); + run(client, cmd_set_setting(handle, channel, value)).await + }, + AncState::Active => { + let value = SettingValue::CurrentAncrState(settings::AncState::Active); + run(client, cmd_set_setting(handle, channel, value)).await + }, + AncState::Adaptive => { + let value = SettingValue::CurrentAncrState(settings::AncState::Adaptive); + run(client, cmd_set_setting(handle, channel, value)).await + }, + AncState::CycleNext => { + run(client, cmd_anc_cycle(handle, channel, true)).await + }, + AncState::CyclePrev => { + run(client, cmd_anc_cycle(handle, channel, false)).await + }, + } + }, + SetSetting::VolumeEq { value } => { + let value = SettingValue::VolumeEqEnable(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::Eq { low_bass, bass, mid, treble, upper_treble } => { + let value = settings::EqBands::new(low_bass, bass, mid, treble, upper_treble); + let value = SettingValue::CurrentUserEq(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::Balance { value } => { + let value = settings::VolumeAsymmetry::from_normalized(value); + let value = SettingValue::VolumeAsymmetry(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::Mono { value } => { + let value = SettingValue::SumToMono(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::VolumeExposureNotifications { value } => { + let value = SettingValue::VolumeExposureNotifications(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + SetSetting::SpeechDetection { value } => { + let value = SettingValue::SpeechDetection(value); + run(client, cmd_set_setting(handle, channel, value)).await + }, + }, + } +} + +async fn cmd_show_software(handle: ClientHandle, channel: u32) -> Result<()> { + let mut service = MaestroService::new(handle, channel); + + let info = service.get_software_info().await?; + + let fw_ver_case = info.firmware.as_ref() + .and_then(|fw| fw.case.as_ref()) + .map(|fw| fw.version_string.as_str()) + .unwrap_or("unknown"); + + let fw_ver_left = info.firmware.as_ref() + .and_then(|fw| fw.left.as_ref()) + .map(|fw| fw.version_string.as_str()) + .unwrap_or("unknown"); + + let fw_ver_right = info.firmware.as_ref() + .and_then(|fw| fw.right.as_ref()) + .map(|fw| fw.version_string.as_str()) + .unwrap_or("unknown"); + + let fw_unk_case = info.firmware.as_ref() + .and_then(|fw| fw.case.as_ref()) + .map(|fw| fw.unknown.as_str()) + .unwrap_or("unknown"); + + let fw_unk_left = info.firmware.as_ref() + .and_then(|fw| fw.left.as_ref()) + .map(|fw| fw.unknown.as_str()) + .unwrap_or("unknown"); + + let fw_unk_right = info.firmware.as_ref() + .and_then(|fw| fw.right.as_ref()) + .map(|fw| fw.unknown.as_str()) + .unwrap_or("unknown"); + + println!("firmware:"); + println!(" case: {fw_ver_case} ({fw_unk_case})"); + println!(" left bud: {fw_ver_left} ({fw_unk_left})"); + println!(" right bud: {fw_ver_right} ({fw_unk_right})"); + + Ok(()) +} + +async fn cmd_show_hardware(handle: ClientHandle, channel: u32) -> Result<()> { + let mut service = MaestroService::new(handle, channel); + + let info = service.get_hardware_info().await?; + + let serial_case = info.serial_number.as_ref() + .map(|ser| ser.case.as_str()) + .unwrap_or("unknown"); + + let serial_left = info.serial_number.as_ref() + .map(|ser| ser.left.as_str()) + .unwrap_or("unknown"); + + let serial_right = info.serial_number.as_ref() + .map(|ser| ser.right.as_str()) + .unwrap_or("unknown"); + + println!("serial numbers:"); + println!(" case: {serial_case}"); + println!(" left bud: {serial_left}"); + println!(" right bud: {serial_right}"); + + Ok(()) +} + +async fn cmd_show_runtime(handle: ClientHandle, channel: u32) -> Result<()> { + let mut service = MaestroService::new(handle, channel); + + let mut call = service.subscribe_to_runtime_info()?; + + let info = call.stream().next().await + .ok_or_else(|| anyhow::anyhow!("stream terminated without item"))??; + + let bat_level_case = info.battery_info.as_ref() + .and_then(|b| b.case.as_ref()) + .map(|b| b.level); + + let bat_state_case = info.battery_info.as_ref() + .and_then(|b| b.case.as_ref()) + .map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" }) + .unwrap_or("unknown"); + + let bat_level_left = info.battery_info.as_ref() + .and_then(|b| b.left.as_ref()) + .map(|b| b.level); + + let bat_state_left = info.battery_info.as_ref() + .and_then(|b| b.left.as_ref()) + .map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" }) + .unwrap_or("unknown"); + + let bat_level_right = info.battery_info.as_ref() + .and_then(|b| b.right.as_ref()) + .map(|b| b.level); + + let bat_state_right = info.battery_info.as_ref() + .and_then(|b| b.right.as_ref()) + .map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" }) + .unwrap_or("unknown"); + + let place_left = info.placement.as_ref() + .map(|p| if p.left_bud_in_case { "in case" } else { "out of case" }) + .unwrap_or("unknown"); + + let place_right = info.placement.as_ref() + .map(|p| if p.right_bud_in_case { "in case" } else { "out of case" }) + .unwrap_or("unknown"); + + println!("clock: {} ms", info.timestamp_ms); + println!(); + + println!("battery:"); + if let Some(lvl) = bat_level_case { + println!(" case: {lvl}% ({bat_state_case})"); + } else { + println!(" case: unknown"); + } + if let Some(lvl) = bat_level_left { + println!(" left bud: {lvl}% ({bat_state_left})"); + } else { + println!(" left bud: unknown"); + } + if let Some(lvl) = bat_level_right { + println!(" right bud: {lvl}% ({bat_state_right})"); + } else { + println!(" right bud: unknown"); + } + println!(); + + println!("placement:"); + println!(" left bud: {place_left}"); + println!(" right bud: {place_right}"); + + let address = addr::address_for_channel(channel); + let peer_local = address.map(|a| a.source()); + let peer_remote = address.map(|a| a.target()); + + println!(); + println!("connection:"); + if let Some(peer) = peer_local { + println!(" local: {peer:?}"); + } else { + println!(" local: unknown"); + } + if let Some(peer) = peer_remote { + println!(" remote: {peer:?}"); + } else { + println!(" remote: unknown"); + } + + Ok(()) +} + +async fn cmd_show_battery(handle: ClientHandle, channel: u32) -> Result<()> { + let mut service = MaestroService::new(handle, channel); + + let mut call = service.subscribe_to_runtime_info()?; + + let info = call.stream().next().await + .ok_or_else(|| anyhow::anyhow!("stream terminated without item"))??; + + let bat_level_case = info.battery_info.as_ref() + .and_then(|b| b.case.as_ref()) + .map(|b| b.level); + + let bat_state_case = info.battery_info.as_ref() + .and_then(|b| b.case.as_ref()) + .map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" }) + .unwrap_or("unknown"); + + let bat_level_left = info.battery_info.as_ref() + .and_then(|b| b.left.as_ref()) + .map(|b| b.level); + + let bat_state_left = info.battery_info.as_ref() + .and_then(|b| b.left.as_ref()) + .map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" }) + .unwrap_or("unknown"); + + let bat_level_right = info.battery_info.as_ref() + .and_then(|b| b.right.as_ref()) + .map(|b| b.level); + + let bat_state_right = info.battery_info.as_ref() + .and_then(|b| b.right.as_ref()) + .map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" }) + .unwrap_or("unknown"); + + if let Some(lvl) = bat_level_case { + println!("case: {lvl}% ({bat_state_case})"); + } else { + println!("case: unknown"); + } + if let Some(lvl) = bat_level_left { + println!("left bud: {lvl}% ({bat_state_left})"); + } else { + println!("left bud: unknown"); + } + if let Some(lvl) = bat_level_right { + println!("right bud: {lvl}% ({bat_state_right})"); + } else { + println!("right bud: unknown"); + } + + Ok(()) +} + +async fn cmd_get_setting(handle: ClientHandle, channel: u32, setting: T) -> Result<()> +where + T: Setting, + T::Type: std::fmt::Display, +{ + let mut service = MaestroService::new(handle, channel); + + let value = service.read_setting(setting).await?; + println!("{value}"); + + Ok(()) +} + +async fn cmd_set_setting(handle: ClientHandle, channel: u32, setting: SettingValue) -> Result<()> { + let mut service = MaestroService::new(handle, channel); + + service.write_setting(setting).await?; + Ok(()) +} + +async fn cmd_anc_cycle(handle: ClientHandle, channel: u32, forward: bool) -> Result<()> { + let mut service = MaestroService::new(handle, channel); + + let enabled = service.read_setting(settings::id::AncrGestureLoop).await?; + let state = service.read_setting(settings::id::CurrentAncrState).await?; + + if let settings::AncState::Unknown(x) = state { + anyhow::bail!("unknown ANC state: {x}"); + } + + let states = [ + (settings::AncState::Active, enabled.active), + (settings::AncState::Off, enabled.off), + (settings::AncState::Aware, enabled.aware), + ]; + + let index = states.iter().position(|(s, _)| *s == state).unwrap(); + + for offs in 1..states.len() { + let next = if forward { + index + offs + } else { + index + states.len() - offs + } % states.len(); + + let (state, enabled) = states[next]; + if enabled { + service.write_setting(SettingValue::CurrentAncrState(state)).await?; + break; + } + } + + Ok(()) +} + +pub async fn run(mut client: Client, task: F) -> Result<()> +where + S: futures::Sink, + S: futures::Stream> + Unpin, + maestro::pwrpc::Error: From, + maestro::pwrpc::Error: From, + F: Future>, +{ + tokio::select! { + res = client.run() => { + res?; + anyhow::bail!("client terminated unexpectedly"); + }, + res = task => { + res?; + tracing::trace!("task terminated successfully"); + } + sig = tokio::signal::ctrl_c() => { + sig?; + tracing::trace!("client termination requested"); + }, + } + + client.terminate().await?; + + tracing::trace!("client terminated successfully"); + Ok(()) +} diff --git a/docs/Notes.md b/docs/Notes.md new file mode 100644 index 0000000..a608653 --- /dev/null +++ b/docs/Notes.md @@ -0,0 +1,35 @@ +# Notes + +The Google Pixel Buds Pro rely on at least two different protocols apart from the standard audio profiles (HSP/HFP, A2DP, AVRCP): +- The Google Fast Pair Service (GFPS) protocol provides support for somewhat standardized events and actions (next to fast-pairing as advertised in its name). + This includes battery status of the individual parts (left/right buds and case), multi-point audio source switching notifications, ringing for find-my-device actions, etc. + See https://developers.google.com/nearby/fast-pair for details. +- The proprietary "Maestro" protocol is used to change settings on the buds (noise-cancelling, equalizer, balance, ...) and likely also update the firmware. + +Note that while AVRCP can provide battery information, this only seems to be a single value for both buds combined and does not include the case. +Detailed battery information can only be obtained via the GFPS protocol. + + +## Google Fast Pair Service Protocol + +See https://developers.google.com/nearby/fast-pair for a somewhat limited specification. +Unfortunately this is incomplete. +More details can be found in the Android source code, e.g. [here][gfps-android-0] and [here][gfps-android-1]. +The Pixel Buds Pro, however, send additional messages with group and code numbers beyond the ones mentioned there. + +The main part of this protocol is a [RFCOMM channel][gfps-rfcomm] which provides events, including battery notifications. +On the Pixel Buds Pro, this also seems to include events for changes to the ANC status (group 8, code 19). + +[gfps-android-0]: https://cs.android.com/android/platform/superproject/+/master:out/soong/.intermediates/packages/modules/Connectivity/nearby/tests/multidevices/clients/test_support/fastpair_provider/proto/NearbyFastPairProviderLiteProtos/android_common/xref/srcjars.xref/android/nearby/fastpair/provider/EventStreamProtocol.java;drc=cb3bd7c37d630acb613e10f730c532128a02a3d5;l=69 +[gfps-android-1]: https://cs.android.com/android/platform/superproject/+/master:packages/modules/Connectivity/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulator.java;l=1199;drc=cb3bd7c37d630acb613e10f730c532128a02a3d5?q=df21fe2c-2515-4fdb-8886-f12c4d67927c&ss=android%2Fplatform%2Fsuperproject +[gfps-rfcomm]: https://developers.google.com/nearby/fast-pair/specifications/extensions/messagestream + + +## Maestro Protocol + +The "Maestro" protocol is a proprietary protocol for changing settings, used on the Pixel Buds Pro. +It's possible that this is targeted more generally at Google wearable devices. +The protocol not only allows for changing settings or getting hardware/firmware information, but also allows for subscribing to events, such as settings changes. + +The protocol is implemented using the [pigweed RPC library](https://pigweed.dev/pw_rpc/), which is similar to [gRPC](https://grpc.io/) and relies on [protocol buffers](https://developers.google.com/protocol-buffers) for message encoding. +In addition, the RPC messages are wrapped in High-Level Data Link Control (HDLC) U-frames (an example for this is given [here](https://pigweed.dev/pw_hdlc/rpc_example/#module-pw-hdlc-rpc-example)). diff --git a/libgfps/Cargo.toml b/libgfps/Cargo.toml new file mode 100644 index 0000000..c5d3207 --- /dev/null +++ b/libgfps/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "gfps" +authors = ["Maximilian Luz "] +version = "0.1.3" +edition = "2024" +license = "MIT/Apache-2.0" +description = "Google Fast Pair Service (GFPS) protocol client library" +repository = "https://github.com/qzed/pbpctrl" + +[dependencies] +bytes = "1.10.1" +num_enum = "0.7.3" +smallvec = { version = "1.15.0", features = ["union"] } +tokio = "1.44.2" +tokio-util = { version = "0.7.14", features = ["codec"] } +uuid = "1.16.0" + +[dev-dependencies] +bluer = { version = "0.17.3", features = ["bluetoothd", "rfcomm"] } +futures = "0.3.31" +pretty-hex = "0.4.1" +tokio = { version = "1.44.2", features = ["rt", "macros"] } diff --git a/libgfps/examples/gfps_get_battery.rs b/libgfps/examples/gfps_get_battery.rs new file mode 100644 index 0000000..61942fd --- /dev/null +++ b/libgfps/examples/gfps_get_battery.rs @@ -0,0 +1,155 @@ +//! Simple example for receiving battery info via the GFPS RFCOMM channel. +//! +//! Usage: +//! cargo run --example gfps_get_battery -- + +use std::str::FromStr; + +use bluer::{Address, Session, Device}; +use bluer::rfcomm::{Profile, ReqError, Role, ProfileHandle}; + +use futures::StreamExt; + +use gfps::msg::{Codec, DeviceEventCode, EventGroup, BatteryInfo}; + +use num_enum::FromPrimitive; + + +#[tokio::main(flavor = "current_thread")] +async fn main() -> bluer::Result<()> { + // handle command line arguments + let addr = std::env::args().nth(1).expect("need device address as argument"); + let addr = Address::from_str(&addr)?; + + // set up session + let session = Session::new().await?; + let adapter = session.default_adapter().await?; + + // get device + let dev = adapter.device(addr)?; + + // get RFCOMM stream + let stream = { + // register GFPS profile + let profile = Profile { + uuid: gfps::msg::UUID, + role: Some(Role::Client), + require_authentication: Some(false), + require_authorization: Some(false), + auto_connect: Some(false), + ..Default::default() + }; + + let mut profile_handle = session.register_profile(profile).await?; + + // connect profile + connect_device_to_profile(&mut profile_handle, &dev).await? + }; + + // listen to event messages + let codec = Codec::new(); + let mut stream = codec.wrap(stream); + + // The battery status cannot be queried via a normal command. However, it + // is sent right after we connect to the GFPS stream. In addition, multiple + // events are often sent in sequence. Therefore we do the following: + // - Set a deadline for a general timeout. If this passes, we just return + // the current state (and if necessary "unknown"): + // - Use a timestamp for checking whether we have received any new updates + // in a given interval. If we have not received any, we consider the + // state to be "settled" and return the battery info. + // - On battery events we simply store the sent information. We retreive + // the stored information once either of the timeouts kicks in. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + + let mut timestamp = deadline; + let mut bat_left = BatteryInfo::Unknown; + let mut bat_right = BatteryInfo::Unknown; + let mut bat_case = BatteryInfo::Unknown; + + let time_settle = std::time::Duration::from_millis(500); + + loop { + tokio::select! { + // receive and handle events + msg = stream.next() => { + match msg { + Some(Ok(msg)) => { + let group = EventGroup::from_primitive(msg.group); + if group != EventGroup::Device { + continue; + } + + let code = DeviceEventCode::from_primitive(msg.code); + if code == DeviceEventCode::BatteryInfo { + timestamp = std::time::Instant::now(); + + bat_left = BatteryInfo::from_byte(msg.data[0]); + bat_right = BatteryInfo::from_byte(msg.data[1]); + bat_case = BatteryInfo::from_byte(msg.data[2]); + } + }, + Some(Err(err)) => { + Err(err)?; + }, + None => { + let err = std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + "connection closed" + ); + + Err(err)?; + } + } + }, + // timeout for determining when the state has "settled" + _ = tokio::time::sleep(tokio::time::Duration::from_millis(time_settle.as_millis() as _)) => { + let delta = std::time::Instant::now() - timestamp; + + if delta > time_settle { + break + } + }, + // general deadline + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => { + break + }, + } + } + + println!("Battery status:"); + println!(" left bud: {}", bat_left); + println!(" right bud: {}", bat_right); + println!(" case: {}", bat_case); + + Ok(()) +} + +async fn connect_device_to_profile(profile: &mut ProfileHandle, dev: &Device) + -> bluer::Result +{ + loop { + tokio::select! { + res = async { + let _ = dev.connect().await; + dev.connect_profile(&gfps::msg::UUID).await + } => { + if let Err(err) = res { + println!("Connecting GFPS profile failed: {:?}", err); + } + tokio::time::sleep(std::time::Duration::from_millis(3000)).await; + }, + req = profile.next() => { + let req = req.expect("no connection request received"); + + if req.device() == dev.address() { + // accept our device + break req.accept(); + } else { + // reject unknown devices + req.reject(ReqError::Rejected); + } + }, + } + } +} diff --git a/libgfps/examples/gfps_listen.rs b/libgfps/examples/gfps_listen.rs new file mode 100644 index 0000000..93ccbd4 --- /dev/null +++ b/libgfps/examples/gfps_listen.rs @@ -0,0 +1,419 @@ +//! Simple example for listening to GFPS messages sent via the RFCOMM channel. +//! +//! Usage: +//! cargo run --example gfps_listen -- + +use std::str::FromStr; + +use bluer::{Address, Session, Device}; +use bluer::rfcomm::{Profile, ReqError, Role, ProfileHandle}; + +use futures::StreamExt; + +use gfps::msg::{ + AcknowledgementEventCode, Codec, DeviceActionEventCode, DeviceCapabilitySyncEventCode, + DeviceConfigurationEventCode, DeviceEventCode, EventGroup, Message, PlatformType, + SassEventCode, LoggingEventCode, BluetoothEventCode, BatteryInfo, +}; + +use num_enum::FromPrimitive; + + +#[tokio::main(flavor = "current_thread")] +async fn main() -> bluer::Result<()> { + // handle command line arguments + let addr = std::env::args().nth(1).expect("need device address as argument"); + let addr = Address::from_str(&addr)?; + + // set up session + let session = Session::new().await?; + let adapter = session.default_adapter().await?; + + println!("Using adapter '{}'", adapter.name()); + + // get device + let dev = adapter.device(addr)?; + let uuids = { + let mut uuids = Vec::from_iter(dev.uuids().await? + .unwrap_or_default() + .into_iter()); + + uuids.sort_unstable(); + uuids + }; + + println!("Found device:"); + println!(" alias: {}", dev.alias().await?); + println!(" address: {}", dev.address()); + println!(" paired: {}", dev.is_paired().await?); + println!(" connected: {}", dev.is_connected().await?); + println!(" UUIDs:"); + for uuid in uuids { + println!(" {}", uuid); + } + println!(); + + // try to reconnect if connection is reset + loop { + let stream = { + // register GFPS profile + println!("Registering GFPS profile..."); + + let profile = Profile { + uuid: gfps::msg::UUID, + role: Some(Role::Client), + require_authentication: Some(false), + require_authorization: Some(false), + auto_connect: Some(false), + ..Default::default() + }; + + let mut profile_handle = session.register_profile(profile).await?; + + // connect profile + println!("Connecting GFPS profile..."); + connect_device_to_profile(&mut profile_handle, &dev).await? + }; + + println!("Profile connected"); + + // listen to event messages + let codec = Codec::new(); + let mut stream = codec.wrap(stream); + + println!("Listening..."); + println!(); + + while let Some(msg) = stream.next().await { + match msg { + Ok(msg) => { + print_message(&msg); + } + Err(e) if e.raw_os_error() == Some(104) => { + // The Pixel Buds Pro can hand off processing between each + // other. On a switch, the connection is reset. Wait a bit + // and then try to reconnect. + println!(); + println!("Connection reset. Attempting to reconnect..."); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + break; + } + Err(e) => { + Err(e)?; + } + } + } + } +} + +async fn connect_device_to_profile(profile: &mut ProfileHandle, dev: &Device) + -> bluer::Result +{ + loop { + tokio::select! { + res = async { + let _ = dev.connect().await; + dev.connect_profile(&gfps::msg::UUID).await + } => { + if let Err(err) = res { + println!("Connecting GFPS profile failed: {:?}", err); + } + tokio::time::sleep(std::time::Duration::from_millis(3000)).await; + }, + req = profile.next() => { + let req = req.expect("no connection request received"); + + if req.device() == dev.address() { + println!("Accepting request..."); + break req.accept(); + } else { + println!("Rejecting unknown device {}", req.device()); + req.reject(ReqError::Rejected); + } + }, + } + } +} + +fn print_message(msg: &Message) { + let group = EventGroup::from_primitive(msg.group); + + match group { + EventGroup::Bluetooth => { + let code = BluetoothEventCode::from_primitive(msg.code); + + println!("Bluetooth (0x{:02X}) :: ", msg.group); + + match code { + BluetoothEventCode::EnableSilenceMode => { + println!("Enable Silence Mode (0x{:02X})", msg.code); + }, + BluetoothEventCode::DisableSilenceMode => { + println!("Disable Silence Mode (0x{:02X})", msg.code); + }, + _ => { + println!("Unknown (0x{:02X})", msg.code); + }, + } + + print_message_body_unknown(msg); + println!(); + } + EventGroup::Logging => { + let code = LoggingEventCode::from_primitive(msg.code); + + println!("Companion App (0x{:02X}) :: ", msg.group); + + match code { + LoggingEventCode::LogFull => { + println!("Log Full (0x{:02X})", msg.code); + } + LoggingEventCode::LogSaveToBuffer => { + println!("Log Save Buffer (0x{:02X})", msg.code); + } + _ => { + println!("Unknown (0x{:02X})", msg.code); + } + } + + print_message_body_unknown(msg); + println!(); + } + EventGroup::Device => { + let code = DeviceEventCode::from_primitive(msg.code); + + print!("Device Information (0x{:02X}) :: ", msg.group); + + match code { + DeviceEventCode::ModelId => { + println!("Model Id (0x{:02X})", msg.code); + println!(" model: {:02X}{:02X}{:02X}", msg.data[0], msg.data[1], msg.data[2]); + } + DeviceEventCode::BleAddress => { + println!("BLE Address (0x{:02X})", msg.code); + println!(" address: {}", Address::new(msg.data[0..6].try_into().unwrap())); + } + DeviceEventCode::BatteryInfo => { + println!("Battery Info (0x{:02X})", msg.code); + + let left = BatteryInfo::from_byte(msg.data[0]); + let right = BatteryInfo::from_byte(msg.data[1]); + let case = BatteryInfo::from_byte(msg.data[2]); + + println!(" left bud: {}", left); + println!(" right bud: {}", right); + println!(" case: {}", case); + } + DeviceEventCode::BatteryTime => { + println!("Remaining Battery Time (0x{:02X})", msg.code); + + let time = match msg.data.len() { + 1 => msg.data[0] as u16, + 2 => u16::from_be_bytes(msg.data[0..2].try_into().unwrap()), + _ => panic!("invalid format"), + }; + + println!(" time: {} minutes", time); + } + DeviceEventCode::ActiveComponentsRequest => { + println!("Active Components Request (0x{:02X})", msg.code); + } + DeviceEventCode::ActiveComponentsResponse => { + println!("Active Components Response (0x{:02X})", msg.code); + println!(" components: {:08b}", msg.data[0]); + } + DeviceEventCode::Capability => { + println!("Capability (0x{:02X})", msg.code); + println!(" capabilities: {:08b}", msg.data[0]); + } + DeviceEventCode::PlatformType => { + println!("Platform Type (0x{:02X})", msg.code); + + let platform = PlatformType::from_primitive(msg.data[0]); + match platform { + PlatformType::Android => { + println!(" platform: Android (0x{:02X})", msg.data[0]); + println!(" SDK version: {:02X?})", msg.data[1]); + } + _ => { + println!(" platform: Unknown (0x{:02X})", msg.data[0]); + println!(" platform data: 0x{:02X?})", msg.data[1]); + } + } + } + DeviceEventCode::FirmwareVersion => { + println!("Firmware Version (0x{:02X})", msg.code); + if let Ok(ver) = std::str::from_utf8(&msg.data) { + println!(" version: {:?}", ver); + } else { + println!(" version: {:02X?}", msg.data); + } + } + DeviceEventCode::SectionNonce => { + println!("Session Nonce (0x{:02X})", msg.code); + println!(" nonce: {:02X?}", msg.data); + } + _ => { + println!("Unknown (0x{:02X})", msg.code); + print_message_body_unknown(msg); + } + } + + println!(); + } + EventGroup::DeviceAction => { + let code = DeviceActionEventCode::from_primitive(msg.code); + + print!("Device Action (0x{:02X}) :: ", msg.group); + + match code { + DeviceActionEventCode::Ring => { + println!("Ring (0x{:02X})", msg.code); + } + _ => { + println!("Unknown (0x{:02X})", msg.code); + } + } + + print_message_body_unknown(msg); + println!(); + } + EventGroup::DeviceConfiguration => { + let code = DeviceConfigurationEventCode::from_primitive(msg.code); + + print!("Device Configuration (0x{:02X}) :: ", msg.group); + + match code { + DeviceConfigurationEventCode::BufferSize => { + println!("Buffer Size (0x{:02X})", msg.code); + } + _ => { + println!("Unknown (0x{:02X})", msg.code); + } + } + + print_message_body_unknown(msg); + println!(); + } + EventGroup::DeviceCapabilitySync => { + let code = DeviceCapabilitySyncEventCode::from_primitive(msg.code); + + print!("Device Cpabilities Sync (0x{:02X}) :: ", msg.group); + + match code { + DeviceCapabilitySyncEventCode::CapabilityUpdate => { + println!("Capability Update (0x{:02X})", msg.code); + } + DeviceCapabilitySyncEventCode::ConfigurableBufferSizeRange => { + println!("Configurable Buffer Size Range (0x{:02X})", msg.code); + } + _ => { + println!("Unknown (0x{:02X})", msg.code); + } + } + + print_message_body_unknown(msg); + println!(); + } + EventGroup::SmartAudioSourceSwitching => { + let code = SassEventCode::from_primitive(msg.code); + + print!("Smart Audio Source Switching (0x{:02X}) :: ", msg.group); + + match code { + SassEventCode::GetCapabilityOfSass => { + println!("Get Capability (0x{:02X})", msg.code); + } + SassEventCode::NotifyCapabilityOfSass => { + println!("Notify Capability (0x{:02X})", msg.code); + } + SassEventCode::SetMultiPointState => { + println!("Set Multi-Point State (0x{:02X})", msg.code); + } + SassEventCode::SwitchAudioSourceBetweenConnectedDevices => { + println!("Switch Audio Source Between Connected Devices (0x{:02X})", msg.code); + } + SassEventCode::SwitchBack => { + println!("Switch Back (0x{:02X})", msg.code); + } + SassEventCode::NotifyMultiPointSwitchEvent => { + println!("Notify Multi-Point (0x{:02X})", msg.code); + } + SassEventCode::GetConnectionStatus => { + println!("Get Connection Status (0x{:02X})", msg.code); + } + SassEventCode::NotifyConnectionStatus => { + println!("Notify Connection Status (0x{:02X})", msg.code); + } + SassEventCode::SassInitiatedConnection => { + println!("SASS Initiated Connection (0x{:02X})", msg.code); + } + SassEventCode::IndicateInUseAccountKey => { + println!("Indicate In-Use Account Key (0x{:02X})", msg.code); + } + SassEventCode::SetCustomData => { + println!("Set Custom Data (0x{:02X})", msg.code); + } + _ => { + println!("Unknown (0x{:02X})", msg.code); + } + } + + print_message_body_unknown(msg); + println!(); + } + EventGroup::Acknowledgement => { + let code = AcknowledgementEventCode::from_primitive(msg.code); + + print!("Acknowledgement (0x{:02X}) ::", msg.group); + + match code { + AcknowledgementEventCode::Ack => { + println!("ACK (0x{:02X})", msg.code); + println!(" group: 0x{:02X}", msg.data[0]); + println!(" code: 0x{:02X}", msg.data[1]); + println!(); + } + AcknowledgementEventCode::Nak => { + println!("NAK (0x{:02X})", msg.code); + match msg.data[0] { + 0x00 => println!(" reason: Not supported (0x00)"), + 0x01 => println!(" reason: Device busy (0x01)"), + 0x02 => println!(" reason: Not allowed due to current state (0x02)"), + _ => println!(" reason: Unknown (0x{:02X})", msg.data[0]), + } + println!(" group: 0x{:02X}", msg.data[1]); + println!(" code: 0x{:02X}", msg.data[2]); + println!(); + } + _ => { + println!("Unknown (0x{:02X})", msg.code); + print_message_body_unknown(msg); + println!(); + } + } + } + _ => { + println!( + "Unknown (0x{:02X}) :: Unknown (0x{:02X})", + msg.group, msg.code + ); + print_message_body_unknown(msg); + println!(); + } + } +} + +fn print_message_body_unknown(msg: &Message) { + let data = pretty_hex::config_hex( + &msg.data, + pretty_hex::HexConfig { + title: false, + ..Default::default() + }, + ); + + for line in data.lines() { + println!(" {}", line); + } +} diff --git a/libgfps/examples/ring.rs b/libgfps/examples/ring.rs new file mode 100644 index 0000000..cf38d8b --- /dev/null +++ b/libgfps/examples/ring.rs @@ -0,0 +1,241 @@ +//! Simple example for "ringing" the buds to locate them. +//! +//! WARNING: DO NOT RUN THIS EXAMPLE WITH THE BUDS IN YOUR EAR! YOU HAVE BEEN WARNED. +//! +//! Usage: +//! cargo run --example ring -- + +use std::str::FromStr; + +use bluer::{Address, Session, Device}; +use bluer::rfcomm::{Profile, Role, ProfileHandle, ReqError}; + +use futures::{StreamExt, SinkExt}; + +use gfps::msg::{Codec, Message, EventGroup, DeviceActionEventCode, AcknowledgementEventCode}; + +use num_enum::FromPrimitive; + +use smallvec::smallvec; + + +#[tokio::main(flavor = "current_thread")] +async fn main() -> bluer::Result<()> { + // handle command line arguments + let addr = std::env::args().nth(1).expect("need device address as argument"); + let addr = Address::from_str(&addr)?; + + // set up session + let session = Session::new().await?; + let adapter = session.default_adapter().await?; + + // get device + let dev = adapter.device(addr)?; + + // get RFCOMM stream + let stream = { + // register GFPS profile + let profile = Profile { + uuid: gfps::msg::UUID, + role: Some(Role::Client), + require_authentication: Some(false), + require_authorization: Some(false), + auto_connect: Some(false), + ..Default::default() + }; + + let mut profile_handle = session.register_profile(profile).await?; + + // connect profile + connect_device_to_profile(&mut profile_handle, &dev).await? + }; + + // set up message stream + let codec = Codec::new(); + let mut stream = codec.wrap(stream); + + // send "ring" message + // + // Note: Pixel Buds Pro ignore messages with a timeout. So don't specify + // one here. + let msg = Message { + group: EventGroup::DeviceAction.into(), + code: DeviceActionEventCode::Ring.into(), + data: smallvec![0x03], // 0b01: right, 0b10: left, 0b10|0b01 = 0b11: both + }; + + println!("Ringing buds..."); + stream.send(&msg).await?; + + // An ACK message should come in 1s. Wait for that. + let timeout = tokio::time::Instant::now() + tokio::time::Duration::from_secs(1); + loop { + tokio::select! { + msg = stream.next() => { + match msg { + Some(Ok(msg)) => { + println!("{:?}", msg); + + let group = EventGroup::from_primitive(msg.group); + if group != EventGroup::Acknowledgement { + continue; + } + + let ack_group = EventGroup::from_primitive(msg.data[0]); + if ack_group != EventGroup::DeviceAction { + continue; + } + + let ack_code = DeviceActionEventCode::from_primitive(msg.data[1]); + if ack_code != DeviceActionEventCode::Ring { + continue; + } + + let code = AcknowledgementEventCode::from_primitive(msg.code); + + if code == AcknowledgementEventCode::Ack { + println!("Received ACK for ring command"); + break; + + } else if code == AcknowledgementEventCode::Nak { + println!("Received NAK for ring command"); + + let err = std::io::Error::new( + std::io::ErrorKind::Unsupported, + "ring has been NAK'ed by device" + ); + + Err(err)?; + } + }, + Some(Err(e)) => { + Err(e)?; + }, + None => { + let err = std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + "connection closed" + ); + + Err(err)?; + } + } + }, + _ = tokio::time::sleep_until(timeout) => { + let err = std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out, ring action might be unsupported" + ); + + Err(err)?; + }, + } + } + + // Next, the device will communicate back status updates. This may include + // an initial update to confirm ringing and follow-up updates once the user + // has touched the buds and ringing stops. + // + // Stop this program once we have no more rining or once we have reached a + // timeout of 30s. + let mut timeout = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30); + loop { + tokio::select! { + msg = stream.next() => { + match msg { + Some(Ok(msg)) => { + println!("{:?}", msg); + + let group = EventGroup::from_primitive(msg.group); + if group != EventGroup::DeviceAction { + continue; + } + // send ACK + let ack = Message { + group: EventGroup::Acknowledgement.into(), + code: AcknowledgementEventCode::Ack.into(), + data: smallvec![msg.group, msg.code], + }; + + stream.send(&ack).await?; + + let status = msg.data[0]; + + println!("Received ring update:"); + + if status & 0b01 != 0 { + println!(" right: ringing"); + } else { + println!(" right: not ringing"); + } + + if status & 0b10 != 0 { + println!(" left: ringing"); + } else { + println!(" left: not ringing"); + } + + if status & 0b11 == 0 { + println!("Buds stopped ringing, exiting..."); + return Ok(()); + } + }, + Some(Err(e)) => { + Err(e)?; + }, + None => { + let err = std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + "connection closed" + ); + + Err(err)?; + } + } + }, + _ = tokio::time::sleep_until(timeout) => { + println!("Sending command to stop ringing..."); + + // send message to stop ringing + let msg = Message { + group: EventGroup::DeviceAction.into(), + code: DeviceActionEventCode::Ring.into(), + data: smallvec![0x00], + }; + + stream.send(&msg).await?; + + timeout = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10); + }, + } + } +} + +async fn connect_device_to_profile(profile: &mut ProfileHandle, dev: &Device) + -> bluer::Result +{ + loop { + tokio::select! { + res = async { + let _ = dev.connect().await; + dev.connect_profile(&gfps::msg::UUID).await + } => { + if let Err(err) = res { + println!("Connecting GFPS profile failed: {:?}", err); + } + tokio::time::sleep(std::time::Duration::from_millis(3000)).await; + }, + req = profile.next() => { + let req = req.expect("no connection request received"); + + if req.device() == dev.address() { + // accept our device + break req.accept(); + } else { + // reject unknown devices + req.reject(ReqError::Rejected); + } + }, + } + } +} diff --git a/libgfps/src/lib.rs b/libgfps/src/lib.rs new file mode 100644 index 0000000..b694142 --- /dev/null +++ b/libgfps/src/lib.rs @@ -0,0 +1,6 @@ +//! Library for the Google Fast Pair Service protocol (GFPS). Focussed on +//! communication via the dedicated GFPS RFCOMM channel. +//! +//! See for the specification. + +pub mod msg; diff --git a/libgfps/src/msg/codec.rs b/libgfps/src/msg/codec.rs new file mode 100644 index 0000000..0c1d9cf --- /dev/null +++ b/libgfps/src/msg/codec.rs @@ -0,0 +1,184 @@ +use super::Message; + +use bytes::{Buf, BytesMut, BufMut}; + +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_util::codec::{Decoder, Framed, Encoder}; + + +const MAX_FRAME_SIZE: u16 = 4096; + + +pub struct Codec {} + +impl Codec { + pub fn new() -> Self { + Self {} + } + + pub fn wrap(self, io: T) -> Framed + where + T: AsyncRead + AsyncWrite, + { + Framed::with_capacity(io, self, MAX_FRAME_SIZE as _) + } +} + +impl Default for Codec { + fn default() -> Self { + Self::new() + } +} + +impl Decoder for Codec { + type Item = Message; + type Error = std::io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + if src.len() < 4 { + return Ok(None); + } + + let group = src[0]; + let code = src[1]; + + let mut length = [0; 2]; + length.copy_from_slice(&src[2..4]); + let length = u16::from_be_bytes(length); + + if length > MAX_FRAME_SIZE { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Frame of length {length} is too large (group: {group}, code: {code})."), + ))?; + } + + let size = 4 + length as usize; + + if src.len() < size as _ { + src.reserve(size - src.len()); + return Ok(None); + } + + let data = src[4..size].into(); + src.advance(size); + + Ok(Some(Message { + group, + code, + data, + })) + } +} + +impl Encoder<&Message> for Codec { + type Error = std::io::Error; + + fn encode(&mut self, msg: &Message, buf: &mut BytesMut) -> Result<(), Self::Error> { + let size = msg.data.len() + 4; + + if size > MAX_FRAME_SIZE as usize { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Frame of length {size} is too large."), + ))?; + } + + buf.reserve(size); + buf.put_u8(msg.group); + buf.put_u8(msg.code); + buf.put_slice(&(msg.data.len() as u16).to_be_bytes()); + buf.put_slice(&msg.data); + + Ok(()) + } +} + + +#[cfg(test)] +mod test { + use super::*; + use crate::msg::{EventGroup, DeviceEventCode, Message}; + + use bytes::BytesMut; + + use smallvec::smallvec; + + + #[test] + fn test_encode() { + let mut buf = BytesMut::new(); + let mut codec = Codec::new(); + + let msg = Message { + group: EventGroup::Device.into(), + code: DeviceEventCode::ModelId.into(), + data: smallvec![0x00, 0x01, 0x02, 0x04, 0x05], + }; + + // try to encode the message + codec.encode(&msg, &mut buf) + .expect("error encode message"); + + let raw = [0x03, 0x01, 0x00, 0x05, 0x00, 0x01, 0x02, 0x04, 0x05]; + assert_eq!(&buf[..], &raw[..]); + } + + #[test] + fn test_decode() { + let mut codec = Codec::new(); + + let raw = [0x03, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02]; + let mut buf = BytesMut::from(&raw[..]); + + let msg = Message { + group: EventGroup::Device.into(), + code: DeviceEventCode::ModelId.into(), + data: smallvec![0x00, 0x01, 0x02], + }; + + // try to encode the message + let decoded = codec.decode(&mut buf) + .expect("error decoding message") + .expect("message incomplete"); + + assert_eq!(decoded, msg); + } + + #[test] + fn test_decode_incomplete() { + let mut codec = Codec::new(); + + let raw = [0x03, 0x01, 0x00, 0x03, 0x00]; + let mut buf = BytesMut::from(&raw[..]); + + // try to encode the message + let decoded = codec.decode(&mut buf) + .expect("error decoding message"); + + assert_eq!(decoded, None); + } + + #[test] + fn test_encode_decode() { + let mut buf = BytesMut::new(); + let mut codec = Codec::new(); + + let msg = Message { + group: 0, + code: 0, + data: smallvec![0x00, 0x01, 0x02], + }; + + // try to encode the message + codec.encode(&msg, &mut buf) + .expect("error encode message"); + + // try to decode the message we just encoded + let decoded = codec.decode(&mut buf) + .expect("error decoding message") + .expect("message incomplete"); + + assert_eq!(decoded, msg); + } +} diff --git a/libgfps/src/msg/mod.rs b/libgfps/src/msg/mod.rs new file mode 100644 index 0000000..e3f6e4d --- /dev/null +++ b/libgfps/src/msg/mod.rs @@ -0,0 +1,14 @@ +//! Types for GFPS Message Stream via RFCOMM. + +use uuid::{uuid, Uuid}; + +/// UUID under which the GFPS Message Stream is advertised. +/// +/// Defined as `df21fe2c-2515-4fdb-8886-f12c4d67927c`. +pub const UUID: Uuid = uuid!("df21fe2c-2515-4fdb-8886-f12c4d67927c"); + +mod codec; +pub use codec::Codec; + +mod types; +pub use types::*; diff --git a/libgfps/src/msg/types.rs b/libgfps/src/msg/types.rs new file mode 100644 index 0000000..badda11 --- /dev/null +++ b/libgfps/src/msg/types.rs @@ -0,0 +1,194 @@ +//! RFCOMM events and event-related enums. + +use std::fmt::Display; + +use num_enum::{IntoPrimitive, FromPrimitive}; + +use smallvec::SmallVec; + + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub group: u8, + pub code: u8, + pub data: SmallVec<[u8; 8]>, +} + + +#[non_exhaustive] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum EventGroup { + Bluetooth = 0x01, + Logging = 0x02, + Device = 0x03, + DeviceAction = 0x04, + DeviceConfiguration = 0x05, + DeviceCapabilitySync = 0x06, + SmartAudioSourceSwitching = 0x07, + Acknowledgement = 0xff, + + #[num_enum(catch_all)] + Unknown(u8) = 0xfe, +} + +#[non_exhaustive] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum BluetoothEventCode { + EnableSilenceMode = 0x01, + DisableSilenceMode = 0x02, + + #[num_enum(catch_all)] + Unknown(u8), +} + +#[non_exhaustive] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum LoggingEventCode { + LogFull = 0x01, + LogSaveToBuffer = 0x02, + + #[num_enum(catch_all)] + Unknown(u8), +} + +#[non_exhaustive] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum DeviceEventCode { + ModelId = 0x01, + BleAddress = 0x02, + BatteryInfo = 0x03, + BatteryTime = 0x04, + ActiveComponentsRequest = 0x05, + ActiveComponentsResponse = 0x06, + Capability = 0x07, + PlatformType = 0x08, + FirmwareVersion = 0x09, + SectionNonce = 0x0a, + + #[num_enum(catch_all)] + Unknown(u8), +} + +#[non_exhaustive] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum DeviceActionEventCode { + Ring = 0x01, + + #[num_enum(catch_all)] + Unknown(u8), +} + +#[non_exhaustive] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum DeviceConfigurationEventCode { + BufferSize = 0x01, + + #[num_enum(catch_all)] + Unknown(u8), +} + +#[non_exhaustive] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum DeviceCapabilitySyncEventCode { + CapabilityUpdate = 0x01, + ConfigurableBufferSizeRange = 0x02, + + #[num_enum(catch_all)] + Unknown(u8), +} + +#[non_exhaustive] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum SassEventCode { + GetCapabilityOfSass = 0x10, + NotifyCapabilityOfSass = 0x11, + SetMultiPointState = 0x12, + SwitchAudioSourceBetweenConnectedDevices = 0x30, + SwitchBack = 0x31, + NotifyMultiPointSwitchEvent = 0x32, + GetConnectionStatus = 0x33, + NotifyConnectionStatus = 0x34, + SassInitiatedConnection = 0x40, + IndicateInUseAccountKey = 0x41, + SetCustomData = 0x42, + + #[num_enum(catch_all)] + Unknown(u8), +} + +#[non_exhaustive] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum AcknowledgementEventCode { + Ack = 0x01, + Nak = 0x02, + + #[num_enum(catch_all)] + Unknown(u8), +} + +#[non_exhaustive] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum PlatformType { + Android = 0x01, + + #[num_enum(catch_all)] + Unknown(u8), +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BatteryInfo { + #[default] + Unknown, + Known { + is_charging: bool, + percent: u8, + }, +} + +impl BatteryInfo { + pub fn from_byte(value: u8) -> Self { + if value & 0x7F == 0x7F { + BatteryInfo::Unknown + } else { + BatteryInfo::Known { + is_charging: (value & 0x80) != 0, + percent: value & 0x7F, + } + } + } + + pub fn to_byte(&self) -> u8 { + match self { + BatteryInfo::Unknown => 0xFF, + BatteryInfo::Known { is_charging: true, percent } => 0x80 | (0x7F & percent), + BatteryInfo::Known { is_charging: false, percent } => 0x7F & percent, + } + } +} + +impl Display for BatteryInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BatteryInfo::Unknown => { + write!(f, "unknown") + } + BatteryInfo::Known { is_charging: true, percent } => { + write!(f, "{percent}% (charging)") + } + BatteryInfo::Known { is_charging: false, percent } => { + write!(f, "{percent}% (not charging)") + } + } + } +} diff --git a/libmaestro/Cargo.toml b/libmaestro/Cargo.toml new file mode 100644 index 0000000..1c3b5f8 --- /dev/null +++ b/libmaestro/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "maestro" +authors = ["Maximilian Luz "] +version = "0.1.5" +edition = "2024" +license = "MIT/Apache-2.0" +description = "Maestro protocol client implementation for controlling Google Pixel Buds Pro" +repository = "https://github.com/qzed/pbpctrl" + +[dependencies] +arrayvec = "0.7.6" +bytes = "1.10.1" +futures = "0.3.31" +num_enum = "0.7.3" +prost = "0.13.5" +tokio = { version = "1.44.2", features = ["macros"] } +tokio-util = { version = "0.7.14", features = ["codec"] } +tracing = "0.1.41" +uuid = "1.16.0" + +[build-dependencies] +prost-build = "0.13.5" + +[dev-dependencies] +anyhow = "1.0.98" +bluer = { version = "0.17.4", features = ["bluetoothd", "rfcomm"] } +futures = "0.3.31" +pretty-hex = "0.4.1" +tokio = { version = "1.44.2", features = ["rt", "macros", "signal"] } +tracing-subscriber = "0.3.19" diff --git a/libmaestro/build.rs b/libmaestro/build.rs new file mode 100644 index 0000000..39336f8 --- /dev/null +++ b/libmaestro/build.rs @@ -0,0 +1,7 @@ +use std::io::Result; + +fn main() -> Result<()> { + prost_build::compile_protos(&["proto/pw.rpc.packet.proto"], &["proto/"])?; + prost_build::compile_protos(&["proto/maestro_pw.proto"], &["proto/"])?; + Ok(()) +} diff --git a/libmaestro/examples/common/mod.rs b/libmaestro/examples/common/mod.rs new file mode 100644 index 0000000..a418910 --- /dev/null +++ b/libmaestro/examples/common/mod.rs @@ -0,0 +1,89 @@ +use std::time::Duration; + +use anyhow::Result; + +use bluer::{Address, Device, Session}; +use bluer::rfcomm::{ProfileHandle, Role, ReqError, Stream, Profile}; + +use futures::StreamExt; + +use maestro::pwrpc::Error; +use maestro::pwrpc::client::Client; +use maestro::pwrpc::types::RpcPacket; + + +pub async fn run_client(mut client: Client) -> Result<()> +where + S: futures::Sink, + S: futures::Stream> + Unpin, + Error: From, + Error: From, +{ + tokio::select! { + res = client.run() => { + res?; + }, + sig = tokio::signal::ctrl_c() => { + sig?; + tracing::trace!("client termination requested"); + }, + } + + client.terminate().await?; + Ok(()) +} + +pub async fn connect_maestro_rfcomm(session: &Session, dev: &Device) -> Result { + let maestro_profile = Profile { + uuid: maestro::UUID, + role: Some(Role::Client), + require_authentication: Some(false), + require_authorization: Some(false), + auto_connect: Some(false), + ..Default::default() + }; + + tracing::debug!("registering maestro profile"); + let mut handle = session.register_profile(maestro_profile).await?; + + tracing::debug!("connecting to maestro profile"); + let stream = tokio::try_join!( + try_connect_profile(dev), + handle_requests_for_profile(&mut handle, dev.address()), + )?.1; + + Ok(stream) +} + +async fn try_connect_profile(dev: &Device) -> Result<()> { + const RETRY_TIMEOUT: Duration = Duration::from_secs(1); + const MAX_TRIES: u32 = 3; + + let mut i = 0; + while let Err(err) = dev.connect_profile(&maestro::UUID).await { + if i >= MAX_TRIES { return Err(err.into()) } + i += 1; + + tracing::warn!(error=?err, "connecting to profile failed, trying again ({}/{})", i, MAX_TRIES); + + tokio::time::sleep(RETRY_TIMEOUT).await; + } + + tracing::debug!(address=%dev.address(), "maestro profile connected"); + Ok(()) +} + +async fn handle_requests_for_profile(handle: &mut ProfileHandle, address: Address) -> Result { + while let Some(req) = handle.next().await { + tracing::debug!(address=%req.device(), "received new profile connection request"); + + if req.device() == address { + tracing::debug!(address=%req.device(), "accepting profile connection request"); + return Ok(req.accept()?); + } else { + req.reject(ReqError::Rejected); + } + } + + anyhow::bail!("profile terminated without requests") +} diff --git a/libmaestro/examples/maestro_get_battery.rs b/libmaestro/examples/maestro_get_battery.rs new file mode 100644 index 0000000..18106ca --- /dev/null +++ b/libmaestro/examples/maestro_get_battery.rs @@ -0,0 +1,139 @@ +//! Simple example for reading battery info via the Maestro service. +//! +//! Usage: +//! cargo run --example maestro_get_battery -- + +mod common; + +use std::str::FromStr; + +use anyhow::bail; +use bluer::{Address, Session}; +use futures::StreamExt; + +use maestro::protocol::codec::Codec; +use maestro::protocol::types::RuntimeInfo; +use maestro::protocol::utils; +use maestro::pwrpc::client::{Client, ClientHandle}; +use maestro::service::MaestroService; + + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), anyhow::Error> { + tracing_subscriber::fmt::init(); + + // handle command line arguments + let addr = std::env::args().nth(1).expect("need device address as argument"); + let addr = Address::from_str(&addr)?; + + // set up session + let session = Session::new().await?; + let adapter = session.default_adapter().await?; + + println!("Using adapter '{}'", adapter.name()); + + // get device + let dev = adapter.device(addr)?; + let uuids = { + let mut uuids = Vec::from_iter(dev.uuids().await? + .unwrap_or_default() + .into_iter()); + + uuids.sort_unstable(); + uuids + }; + + println!("Found device:"); + println!(" alias: {}", dev.alias().await?); + println!(" address: {}", dev.address()); + println!(" paired: {}", dev.is_paired().await?); + println!(" connected: {}", dev.is_connected().await?); + println!(" UUIDs:"); + for uuid in uuids { + println!(" {}", uuid); + } + println!(); + + println!("Connecting to Maestro profile"); + let stream = common::connect_maestro_rfcomm(&session, &dev).await?; + + println!("Profile connected"); + + // set up stream for RPC communication + let codec = Codec::new(); + let stream = codec.wrap(stream); + + // set up RPC client + let mut client = Client::new(stream); + let handle = client.handle(); + + // retreive the channel numer + let channel = utils::resolve_channel(&mut client).await?; + + let exec_task = common::run_client(client); + let battery_task = get_battery(handle, channel); + + let info = tokio::select! { + res = exec_task => { + match res { + Ok(_) => bail!("client terminated unexpectedly without error"), + Err(e) => Err(e), + } + }, + res = battery_task => res, + }?; + + let info = info.battery_info + .expect("did not receive battery status in runtime-info-changed event"); + + println!("Battery status:"); + + if let Some(info) = info.case { + match info.state { + 1 => println!(" case: {}% (not charging)", info.level), + 2 => println!(" case: {}% (charging)", info.level), + x => println!(" case: {}% (unknown state: {})", info.level, x), + } + } else { + println!(" case: unknown"); + } + + if let Some(info) = info.left { + match info.state { + 1 => println!(" left: {}% (not charging)", info.level), + 2 => println!(" left: {}% (charging)", info.level), + x => println!(" left: {}% (unknown state: {})", info.level, x), + } + } else { + println!(" left: unknown"); + } + + if let Some(info) = info.right { + match info.state { + 1 => println!(" right: {}% (not charging)", info.level), + 2 => println!(" right: {}% (charging)", info.level), + x => println!(" right: {}% (unknown state: {})", info.level, x), + } + } else { + println!(" right: unknown"); + } + + Ok(()) +} + +async fn get_battery(handle: ClientHandle, channel: u32) -> anyhow::Result { + println!("Reading battery info..."); + println!(); + + let mut service = MaestroService::new(handle, channel); + + let mut call = service.subscribe_to_runtime_info()?; + let rt_info = if let Some(msg) = call.stream().next().await { + msg? + } else { + bail!("did not receive any runtime-info event"); + }; + + call.cancel_and_wait().await?; + Ok(rt_info) +} diff --git a/libmaestro/examples/maestro_listen.rs b/libmaestro/examples/maestro_listen.rs new file mode 100644 index 0000000..2039e34 --- /dev/null +++ b/libmaestro/examples/maestro_listen.rs @@ -0,0 +1,172 @@ +//! Simple example for listening to Maestro messages sent via the RFCOMM channel. +//! +//! Usage: +//! cargo run --example maestro_listen -- + +mod common; + +use std::str::FromStr; + +use bluer::{Address, Session}; +use futures::StreamExt; + +use maestro::protocol::codec::Codec; +use maestro::protocol::utils; +use maestro::pwrpc::client::{Client, ClientHandle}; +use maestro::service::{MaestroService, DosimeterService}; + + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), anyhow::Error> { + tracing_subscriber::fmt::init(); + + // handle command line arguments + let addr = std::env::args().nth(1).expect("need device address as argument"); + let addr = Address::from_str(&addr)?; + + // set up session + let session = Session::new().await?; + let adapter = session.default_adapter().await?; + + println!("Using adapter '{}'", adapter.name()); + + // get device + let dev = adapter.device(addr)?; + let uuids = { + let mut uuids = Vec::from_iter(dev.uuids().await? + .unwrap_or_default() + .into_iter()); + + uuids.sort_unstable(); + uuids + }; + + println!("Found device:"); + println!(" alias: {}", dev.alias().await?); + println!(" address: {}", dev.address()); + println!(" paired: {}", dev.is_paired().await?); + println!(" connected: {}", dev.is_connected().await?); + println!(" UUIDs:"); + for uuid in uuids { + println!(" {}", uuid); + } + println!(); + + // try to reconnect if connection is reset + loop { + println!("Connecting to Maestro profile"); + let stream = common::connect_maestro_rfcomm(&session, &dev).await?; + + println!("Profile connected"); + + // set up stream for RPC communication + let codec = Codec::new(); + let stream = codec.wrap(stream); + + // set up RPC client + let mut client = Client::new(stream); + let handle = client.handle(); + + // retreive the channel numer + let channel = utils::resolve_channel(&mut client).await?; + + let exec_task = common::run_client(client); + let listen_task = run_listener(handle, channel); + + tokio::select! { + res = exec_task => { + match res { + Ok(_) => { + tracing::trace!("client terminated successfully"); + return Ok(()); + }, + Err(e) => { + tracing::error!("client task terminated with error"); + + let cause = e.root_cause(); + if let Some(cause) = cause.downcast_ref::() { + if cause.raw_os_error() == Some(104) { + // The Pixel Buds Pro can hand off processing between each + // other. On a switch, the connection is reset. Wait a bit + // and then try to reconnect. + println!(); + println!("Connection reset. Attempting to reconnect..."); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + continue; + } + } + + return Err(e); + }, + } + }, + res = listen_task => { + match res { + Ok(_) => { + tracing::error!("server terminated stream"); + return Ok(()); + } + Err(e) => { + tracing::error!("main task terminated with error"); + return Err(e); + } + } + }, + } + } +} + +async fn run_listener(handle: ClientHandle, channel: u32) -> anyhow::Result<()> { + println!("Sending GetSoftwareInfo request"); + println!(); + + let mut service = MaestroService::new(handle.clone(), channel); + let mut dosimeter = DosimeterService::new(handle, channel); + + let info = service.get_software_info().await?; + println!("{:#?}", info); + + let info = dosimeter.fetch_daily_summaries().await?; + println!("{:#?}", info); + + println!(); + println!("Listening to settings changes..."); + println!(); + + let task_rtinfo = run_listener_rtinfo(service.clone()); + let task_settings = run_listener_settings(service.clone()); + let task_dosimeter = run_listener_dosimeter(dosimeter.clone()); + + tokio::select! { + res = task_rtinfo => res, + res = task_settings => res, + res = task_dosimeter => res, + } +} + +async fn run_listener_rtinfo(mut service: MaestroService) -> anyhow::Result<()> { + let mut call = service.subscribe_to_runtime_info()?; + while let Some(msg) = call.stream().next().await { + println!("{:#?}", msg?); + } + + Ok(()) +} + +async fn run_listener_settings(mut service: MaestroService) -> anyhow::Result<()> { + let mut call = service.subscribe_to_settings_changes()?; + while let Some(msg) = call.stream().next().await { + println!("{:#?}", msg?); + } + + Ok(()) +} + +async fn run_listener_dosimeter(mut service: DosimeterService) -> anyhow::Result<()> { + let mut call = service.subscribe_to_live_db()?; + while let Some(msg) = call.stream().next().await { + println!("volume: {:#?} dB", (msg.unwrap().intensity.log10() * 10.0).round()); + } + + Ok(()) +} diff --git a/libmaestro/examples/maestro_read_settings.rs b/libmaestro/examples/maestro_read_settings.rs new file mode 100644 index 0000000..7b8d42b --- /dev/null +++ b/libmaestro/examples/maestro_read_settings.rs @@ -0,0 +1,152 @@ +//! Simple example for reading settings on the Pixel Buds Pro via the Maestro service. +//! +//! Usage: +//! cargo run --example maestro_read_settings -- + +mod common; + +use std::str::FromStr; + +use anyhow::bail; +use bluer::{Address, Session}; + +use maestro::protocol::codec::Codec; +use maestro::protocol::utils; +use maestro::pwrpc::client::{Client, ClientHandle}; +use maestro::service::MaestroService; +use maestro::service::settings::{self, SettingId}; + + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), anyhow::Error> { + tracing_subscriber::fmt::init(); + + // handle command line arguments + let addr = std::env::args().nth(1).expect("need device address as argument"); + let addr = Address::from_str(&addr)?; + + // set up session + let session = Session::new().await?; + let adapter = session.default_adapter().await?; + + println!("Using adapter '{}'", adapter.name()); + + // get device + let dev = adapter.device(addr)?; + let uuids = { + let mut uuids = Vec::from_iter(dev.uuids().await? + .unwrap_or_default() + .into_iter()); + + uuids.sort_unstable(); + uuids + }; + + println!("Found device:"); + println!(" alias: {}", dev.alias().await?); + println!(" address: {}", dev.address()); + println!(" paired: {}", dev.is_paired().await?); + println!(" connected: {}", dev.is_connected().await?); + println!(" UUIDs:"); + for uuid in uuids { + println!(" {}", uuid); + } + println!(); + + println!("Connecting to Maestro profile"); + let stream = common::connect_maestro_rfcomm(&session, &dev).await?; + + println!("Profile connected"); + + // set up stream for RPC communication + let codec = Codec::new(); + let stream = codec.wrap(stream); + + // set up RPC client + let mut client = Client::new(stream); + let handle = client.handle(); + + // retreive the channel numer + let channel = utils::resolve_channel(&mut client).await?; + + let exec_task = common::run_client(client); + let settings_task = read_settings(handle, channel); + + tokio::select! { + res = exec_task => { + match res { + Ok(_) => bail!("client terminated unexpectedly without error"), + Err(e) => Err(e), + } + }, + res = settings_task => res, + } +} + +async fn read_settings(handle: ClientHandle, channel: u32) -> anyhow::Result<()> { + let mut service = MaestroService::new(handle.clone(), channel); + + println!(); + println!("Read via types:"); + + // read some typed settings via proxy structs + let value = service.read_setting(settings::id::AutoOtaEnable).await?; + println!(" Auto-OTA enabled: {}", value); + + let value = service.read_setting(settings::id::OhdEnable).await?; + println!(" OHD enabled: {}", value); + + let value = service.read_setting(settings::id::OobeIsFinished).await?; + println!(" OOBE finished: {}", value); + + let value = service.read_setting(settings::id::GestureEnable).await?; + println!(" Gestures enabled: {}", value); + + let value = service.read_setting(settings::id::DiagnosticsEnable).await?; + println!(" Diagnostics enabled: {}", value); + + let value = service.read_setting(settings::id::OobeMode).await?; + println!(" OOBE mode: {}", value); + + let value = service.read_setting(settings::id::GestureControl).await?; + println!(" Gesture control: {}", value); + + let value = service.read_setting(settings::id::MultipointEnable).await?; + println!(" Multi-point enabled: {}", value); + + let value = service.read_setting(settings::id::AncrGestureLoop).await?; + println!(" ANCR gesture loop: {}", value); + + let value = service.read_setting(settings::id::CurrentAncrState).await?; + println!(" ANC status: {}", value); + + let value = service.read_setting(settings::id::OttsMode).await?; + println!(" OTTS mode: {}", value); + + let value = service.read_setting(settings::id::VolumeEqEnable).await?; + println!(" Volume-EQ enabled: {}", value); + + let value = service.read_setting(settings::id::CurrentUserEq).await?; + println!(" Current user EQ: {}", value); + + let value = service.read_setting(settings::id::VolumeAsymmetry).await?; + println!(" Volume balance/asymmetry: {}", value); + + let value = service.read_setting(settings::id::SumToMono).await?; + println!(" Mono output: {}", value); + + let value = service.read_setting(settings::id::VolumeExposureNotifications).await?; + println!(" Volume level exposure notifications: {}", value); + + let value = service.read_setting(settings::id::SpeechDetection).await?; + println!(" Speech detection: {}", value); + + // read settings via variant + println!(); + println!("Read via variants:"); + + let value = service.read_setting(SettingId::GestureEnable).await?; + println!(" Gesture enable: {:?}", value); + + Ok(()) +} diff --git a/libmaestro/examples/maestro_write_settings.rs b/libmaestro/examples/maestro_write_settings.rs new file mode 100644 index 0000000..bf6cf75 --- /dev/null +++ b/libmaestro/examples/maestro_write_settings.rs @@ -0,0 +1,106 @@ +//! Simple example for changing settings on the Pixel Buds Pro via the Maestro service. +//! +//! Sets active nois ecancelling (ANC) state. 1: off, 2: active, 3: aware, 4.adaptive +//! +//! Usage: +//! cargo run --example maestro_write_settings -- + +mod common; + +use std::str::FromStr; + +use anyhow::bail; +use bluer::{Address, Session}; +use maestro::protocol::utils; +use num_enum::FromPrimitive; + +use maestro::protocol::codec::Codec; +use maestro::pwrpc::client::{Client, ClientHandle}; +use maestro::service::MaestroService; +use maestro::service::settings::{AncState, SettingValue}; + + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), anyhow::Error> { + tracing_subscriber::fmt::init(); + + // handle command line arguments + let addr = std::env::args().nth(1).expect("need device address as argument"); + let addr = Address::from_str(&addr)?; + + let anc_state = std::env::args().nth(2).expect("need ANC state as argument"); + let anc_state = i32::from_str(&anc_state)?; + let anc_state = AncState::from_primitive(anc_state); + + if let AncState::Unknown(x) = anc_state { + bail!("invalid ANC state {x}"); + } + + // set up session + let session = Session::new().await?; + let adapter = session.default_adapter().await?; + + println!("Using adapter '{}'", adapter.name()); + + // get device + let dev = adapter.device(addr)?; + let uuids = { + let mut uuids = Vec::from_iter(dev.uuids().await? + .unwrap_or_default() + .into_iter()); + + uuids.sort_unstable(); + uuids + }; + + println!("Found device:"); + println!(" alias: {}", dev.alias().await?); + println!(" address: {}", dev.address()); + println!(" paired: {}", dev.is_paired().await?); + println!(" connected: {}", dev.is_connected().await?); + println!(" UUIDs:"); + for uuid in uuids { + println!(" {}", uuid); + } + println!(); + + println!("Connecting to Maestro profile"); + let stream = common::connect_maestro_rfcomm(&session, &dev).await?; + + println!("Profile connected"); + + // set up stream for RPC communication + let codec = Codec::new(); + let stream = codec.wrap(stream); + + // set up RPC client + let mut client = Client::new(stream); + let handle = client.handle(); + + // retreive the channel numer + let channel = utils::resolve_channel(&mut client).await?; + + let exec_task = common::run_client(client); + let settings_task = read_settings(handle, channel, anc_state); + + tokio::select! { + res = exec_task => { + match res { + Ok(_) => bail!("client terminated unexpectedly without error"), + Err(e) => Err(e), + } + }, + res = settings_task => res, + } +} + +async fn read_settings(handle: ClientHandle, channel: u32, anc_state: AncState) -> anyhow::Result<()> { + let mut service = MaestroService::new(handle.clone(), channel); + + println!(); + println!("Setting ANC status to '{}'", anc_state); + + service.write_setting(SettingValue::CurrentAncrState(anc_state)).await?; + + Ok(()) +} diff --git a/libmaestro/proto/maestro_pw.proto b/libmaestro/proto/maestro_pw.proto new file mode 100644 index 0000000..32aa466 --- /dev/null +++ b/libmaestro/proto/maestro_pw.proto @@ -0,0 +1,301 @@ +syntax = "proto3"; + +package maestro_pw; + +import "google/protobuf/empty.proto"; + + +/* -- Maestro Service --------------------------------------------------------------------------- */ + +message SoftwareInfo { + int32 unknown2 = 2; + FirmwareInfo firmware = 4; + fixed64 unknown5 = 5; + bool unknown6 = 6; +} + +message FirmwareInfo { + // Note: order might not be correct + FirmwareVersion case = 1; + FirmwareVersion right = 2; + FirmwareVersion left = 3; +} + +message FirmwareVersion { + string unknown = 1; + string version_string = 2; +} + +message HardwareInfo { + int32 unknown1 = 1; + int32 unknown2 = 2; + int32 unknown5 = 5; + int32 unknown6 = 6; + SerialNumbers serial_number = 7; +} + +message SerialNumbers { + string case = 1; + string right = 2; + string left = 3; +} + +message RuntimeInfo { + int64 timestamp_ms = 2; // maybe unix time in ms (consistent ~60s difference to actual time) + int32 unknown3 = 3; + BatteryInfo battery_info = 6; + PlacementInfo placement = 7; +} + +message BatteryInfo { + DeviceBatteryInfo case = 1; + DeviceBatteryInfo left = 2; + DeviceBatteryInfo right = 3; +} + +message DeviceBatteryInfo { + int32 level = 1; // battery level in percent + BatteryState state = 2; +} + +enum BatteryState { + BATTERY_STATE_UNKNOWN = 0; + BATTERY_NOT_CHARGING = 1; + BATTERY_CHARGING = 2; +} + +message PlacementInfo { + bool right_bud_in_case = 1; + bool left_bud_in_case = 2; +} + +message WallClockMsg { + // TODO +} + +message ReadSettingMsg { + oneof value_oneof { + AllegroSettingType settings_id = 4; + } +} + +enum AllegroSettingType { + ALLEGRO_SETTING_TYPE_UNKNOWN = 0; + ALLEGRO_AUTO_OTA_ENABLE = 1; + ALLEGRO_OHD_ENABLE = 2; + ALLEGRO_OOBE_IS_FINISHED = 3; + ALLEGRO_GESTURE_ENABLE = 4; + ALLEGRO_DIAGNOSTICS_ENABLE = 5; + ALLEGRO_OOBE_MODE = 6; + ALLEGRO_GESTURE_CONTROL = 7; + ALLEGRO_ANC_ACCESSIBILITY_MODE = 8; + ALLEGRO_ANCR_STATE_ONE_BUD = 9; + ALLEGRO_ANCR_STATE_TWO_BUDS = 10; + ALLEGRO_MULTIPOINT_ENABLE = 11; + ALLEGRO_ANCR_GESTURE_LOOP = 12; + ALLEGRO_CURRENT_ANCR_STATE = 13; + ALLEGRO_OTTS_MODE = 14; + ALLEGRO_VOLUME_EQ_ENABLE = 15; + ALLEGRO_CURRENT_USER_EQ = 16; + ALLEGRO_VOLUME_ASYMMETRY = 17; + ALLEGRO_LAST_SAVED_USER_EQ = 18; +} + +message WriteSettingMsg { + oneof value_oneof { + SettingValue setting = 4; + } +} + +message SettingsRsp { + oneof value_oneof { + SettingValue value = 4; + } +} + +message SettingValue { + oneof value_oneof { + bool auto_ota_enable = 1; + bool ohd_enable = 2; // on-head detection + bool oobe_is_finished = 3; // out-of-box experience? + bool gesture_enable = 4; + bool diagnostics_enable = 5; + bool oobe_mode = 6; + GestureControl gesture_control = 7; + // reading anc_accessibility_mode returns non-zero status (code: 2) + // reading ancr_state_one_bud returns non-zero status (code: 2) + // reading ancr_state_two_buds returns non-zero status (code: 2) + bool multipoint_enable = 11; + AncrGestureLoop ancr_gesture_loop = 12; + AncState current_ancr_state = 13; + int32 otts_mode = 14; // might be bool + bool volume_eq_enable = 15; + EqBands current_user_eq = 16; + int32 volume_asymmetry = 17; // value goes from 0 t0 200 (incl.), even/odd indicates left/right + // reading last_saved_user_eq returns non-zero status (code: 2) + bool sum_to_mono = 19; + // id 20 does not seem to exist (yet?) + bool volume_exposure_notifications = 21; + bool speech_detection = 22; + } +} + +message GestureControl { + DeviceGestureControl left = 1; + DeviceGestureControl right = 2; +} + +message DeviceGestureControl { + oneof value_oneof { + GestureControlType type = 4; + } +} + +message GestureControlType { + RegularActionTarget value = 1; +} + +enum RegularActionTarget { + ACTION_TARGET_UNKNOWN = 0; + ACTION_TARGET_CHECK_NOTIFICATIONS = 1; + ACTION_TARGET_PREVIOUS_TRACK_REPEAT = 2; + ACTION_TARGET_NEXT_TRACK = 3; + ACTION_TARGET_PLAY_PAUSE_TRACK = 4; + ACTION_TARGET_ANC_CONTROL = 5; + ACTION_TARGET_ASSISTANT_QUERY = 6; +} + +message AncrGestureLoop { + bool active = 1; + bool off = 2; + bool aware = 3; + bool adaptive = 4; +} + +enum AncState { + ANC_STATE_UNKNOWN = 0; + ANC_STATE_OFF = 1; + ANC_STATE_ACTIVE = 2; + ANC_STATE_AWARE = 3; + ANC_STATE_ADAPTIVE = 4; +} + +message EqBands { + // bands go from -6.0 to 6.0 + float low_bass = 1; + float bass = 2; + float mid = 3; + float treble = 4; + float upper_treble = 5; +} + +message OobeActionRsp { + OobeAction action = 1; +} + +enum OobeAction { + OOBE_ACTION_UNKNOWN = 0; + OOBE_ACTION_SINGLE_TAP = 1; + OOBE_ACTION_DOUBLE_TAP = 2; + OOBE_ACTION_TRIPLE_TAP = 3; + OOBE_ACTION_HOLD = 4; + OOBE_ACTION_SWIPE_FORWARD = 5; + OOBE_ACTION_SWIPE_BACKWARD = 6; + OOBE_ACTION_SWIPE_UP = 7; + OOBE_ACTION_SWIPE_DOWN = 8; + OOBE_ACTION_HOTWORD = 9; + OOBE_ACTION_LEFT_ON_HEAD = 10; + OOBE_ACTION_LEFT_OFF_HEAD = 11; + OOBE_ACTION_RIGHT_ON_HEAD = 12; + OOBE_ACTION_RIGHT_OFF_HEAD = 13; + OOBE_ACTION_SPECULATIVE_TAP = 14; + OOBE_ACTION_HOLD_END = 15; + OOBE_ACTION_HOLD_CANCEL = 16; +} + +service Maestro { + rpc GetSoftwareInfo(google.protobuf.Empty) returns (SoftwareInfo) {} + rpc GetHardwareInfo(google.protobuf.Empty) returns (HardwareInfo) {} + rpc SubscribeRuntimeInfo(google.protobuf.Empty) returns (stream RuntimeInfo) {} + rpc SetWallClock(WallClockMsg) returns (google.protobuf.Empty) {} + rpc WriteSetting(WriteSettingMsg) returns (google.protobuf.Empty) {} + rpc ReadSetting(ReadSettingMsg) returns (SettingsRsp) {} + rpc SubscribeToSettingsChanges(google.protobuf.Empty) returns (stream SettingsRsp) {} + rpc SubscribeToOobeActions(google.protobuf.Empty) returns (stream OobeActionRsp) {} +} + + +/* -- Multipoint Service ------------------------------------------------------------------------ */ + +message QuietModeStatusEvent { + int32 source = 1; +} + +message ForceMultipointSwitchMsg { + // TODO +} + +service Multipoint { + rpc SubscribeToQuietModeStatus(google.protobuf.Empty) returns (stream QuietModeStatusEvent) {} + rpc ForceMultipointSwitch(ForceMultipointSwitchMsg) returns (google.protobuf.Empty) {} +} + + +/* -- EartipFitTest Service --------------------------------------------------------------------- */ + +message StartEartipFitTestMsg { + // TODO +} + +message EndEartipFitTestMsg { + // TODO +} + +message SubscribeToEartipFitTestResultsMsg { + // TODO +} + +message EartipFitTestResult { + // TODO +} + +service EartipFitTest { + rpc StartTest(StartEartipFitTestMsg) returns (google.protobuf.Empty) {} + rpc EndTest(StartEartipFitTestMsg) returns (google.protobuf.Empty) {} + rpc SubscribeToResults(SubscribeToEartipFitTestResultsMsg) returns (stream EartipFitTestResult) {} +} + + +/* -- JitterBuffer Service ---------------------------------------------------------------------- */ + +message SetJitterBufferSizePreferenceMsg { + // TODO +} + +service JitterBuffer { + rpc SetJitterBufferSizePreference(SetJitterBufferSizePreferenceMsg) returns (google.protobuf.Empty) {} +} + + +/* -- Dosimeter Service ------------------------------------------------------------------------- */ + +message DosimeterSummaryEntry { + int32 unknown1 = 1; + float unknown6 = 6; +} + +message DosimeterSummary { + int32 unknown1 = 1; + repeated DosimeterSummaryEntry unknown2 = 2; + int32 unknown4 = 4; + float unknown5 = 5; +} + +message DosimeterLiveDbMsg { + float intensity = 2; // convert to dB via log10(x) * 10 +} + +service Dosimeter { + rpc FetchDailySummaries(google.protobuf.Empty) returns (DosimeterSummary) {} + rpc SubscribeToLiveDb(google.protobuf.Empty) returns (DosimeterLiveDbMsg) {} +} diff --git a/libmaestro/proto/pw.rpc.packet.proto b/libmaestro/proto/pw.rpc.packet.proto new file mode 100644 index 0000000..0b084ca --- /dev/null +++ b/libmaestro/proto/pw.rpc.packet.proto @@ -0,0 +1,87 @@ +// Copied from pigweed RPC library with modifications. +// - Changed package specification. +// - Removed java-package option. +// +// Original copyright: +// Copyright 2020 The Pigweed Authors +// +// 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 +// +// https://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. +syntax = "proto3"; + +package pw.rpc.packet; + +enum PacketType { + // To simplify identifying the origin of a packet, client-to-server packets + // use even numbers and server-to-client packets use odd numbers. + + // Client-to-server packets + + // The client invokes an RPC. Always the first packet. + REQUEST = 0; + + // A message in a client stream. Always sent after a REQUEST and before a + // CLIENT_STREAM_END. + CLIENT_STREAM = 2; + + // The client received a packet for an RPC it did not request. + CLIENT_ERROR = 4; + + // Deprecated, do not use. Send a CLIENT_ERROR with status CANCELLED instead. + // TODO(b/234879973): Remove this packet type. + DEPRECATED_CANCEL = 6; + + // A client stream has completed. + CLIENT_STREAM_END = 8; + + // Server-to-client packets + + // The RPC has finished. + RESPONSE = 1; + + // Deprecated, do not use. Formerly was used as the last packet in a server + // stream. + // TODO(b/234879973): Remove this packet type. + DEPRECATED_SERVER_STREAM_END = 3; + + // The server was unable to process a request. + SERVER_ERROR = 5; + + // A message in a server stream. + SERVER_STREAM = 7; +} + +message RpcPacket { + // The type of packet. Determines which other fields are used. + PacketType type = 1; + + // Channel through which the packet is sent. + uint32 channel_id = 2; + + // Hash of the fully-qualified name of the service with which this packet is + // associated. For RPC packets, this is the service that processes the packet. + fixed32 service_id = 3; + + // Hash of the name of the method which should process this packet. + fixed32 method_id = 4; + + // The packet's payload, which is an encoded protobuf. + bytes payload = 5; + + // Status code for the RPC response or error. + uint32 status = 6; + + // Unique identifier for the call that initiated this RPC. Optionally set by + // the client in the initial request and sent in all subsequent client + // packets; echoed by the server. + uint32 call_id = 7; +} diff --git a/libmaestro/src/hdlc/codec.rs b/libmaestro/src/hdlc/codec.rs new file mode 100644 index 0000000..b9813d6 --- /dev/null +++ b/libmaestro/src/hdlc/codec.rs @@ -0,0 +1,72 @@ +use super::{decoder, encoder, Frame}; + +use bytes::BytesMut; + +use tokio::io::{AsyncWrite, AsyncRead}; +use tokio_util::codec::Framed; + + +#[derive(Debug)] +pub enum DecoderError { + Io(std::io::Error), + Decoder(decoder::Error), +} + +impl From for DecoderError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for DecoderError { + fn from(value: decoder::Error) -> Self { + Self::Decoder(value) + } +} + + +#[derive(Debug, Default)] +pub struct Codec { + dec: decoder::Decoder, +} + +impl Codec { + pub fn new() -> Self { + Self { dec: decoder::Decoder::new() } + } + + pub fn with_capacity(cap: usize) -> Self { + Self { dec: decoder::Decoder::with_capacity(cap) } + } + + pub fn wrap(self, io: T) -> Framed + where + T: AsyncRead + AsyncWrite, + { + Framed::with_capacity(io, self, 4096 as _) + } +} + +impl tokio_util::codec::Encoder<&Frame> for Codec { + type Error = std::io::Error; + + fn encode(&mut self, frame: &Frame, dst: &mut BytesMut) -> Result<(), Self::Error> { + encoder::encode(dst, frame); + Ok(()) + } +} + +impl tokio_util::codec::Decoder for Codec { + type Item = Frame; + type Error = std::io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + match self.dec.process(src) { + Ok(x) => Ok(x), + Err(e) => { + tracing::warn!("error decoding data: {e:?}"); + Ok(None) + }, + } + } +} diff --git a/libmaestro/src/hdlc/consts.rs b/libmaestro/src/hdlc/consts.rs new file mode 100644 index 0000000..5d9a76c --- /dev/null +++ b/libmaestro/src/hdlc/consts.rs @@ -0,0 +1,10 @@ +//! Flag bytes and bit masks used in the HDLC encoding. + +pub mod flags { + pub const FRAME: u8 = 0x7E; + pub const ESCAPE: u8 = 0x7D; +} + +pub mod escape { + pub const MASK: u8 = 0x20; +} diff --git a/libmaestro/src/hdlc/crc.rs b/libmaestro/src/hdlc/crc.rs new file mode 100644 index 0000000..9384cfa --- /dev/null +++ b/libmaestro/src/hdlc/crc.rs @@ -0,0 +1,109 @@ +//! 32-bit CRC implementation. + +#[derive(Debug)] +pub struct Crc32 { + state: u32, +} + +impl Crc32 { + pub fn new() -> Self { + Self::with_state(0xFFFFFFFF) + } + + pub fn with_state(state: u32) -> Self { + Self { state } + } + + pub fn reset(&mut self) { + self.state = 0xFFFFFFFF; + } + + pub fn value(&self) -> u32 { + !self.state + } + + pub fn put_u8(&mut self, byte: u8) -> &mut Self { + self.state = tables::CRC32[((self.state as u8) ^ byte) as usize] ^ (self.state >> 8); + self + } + + pub fn put_bytes<'a, B: IntoIterator>(&mut self, bytes: B) -> &mut Self { + for b in bytes.into_iter().copied() { + self.put_u8(b); + } + + self + } +} + +impl Default for Crc32 { + fn default() -> Self { + Self::new() + } +} + + +pub fn crc32<'a, B: IntoIterator>(bytes: B) -> u32 { + Crc32::new().put_bytes(bytes).value() +} + + +mod tables { + pub const CRC32: [u32; 256] = [ + 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, + 0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, + 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2, + 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, + 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, + 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, + 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c, + 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, + 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, + 0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, + 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106, + 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, + 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, + 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, + 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950, + 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, + 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, + 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, + 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, + 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, + 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, + 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, + 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84, + 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, + 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, + 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, + 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e, + 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, + 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, + 0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, + 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, + 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, + 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f, + 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, + 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, + 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, + 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, + 0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, + 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc, + 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, + 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, + 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, + 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d, + ]; +} + + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_crc32() { + assert_eq!(crc32(b"test test test"), 0x235b6a02); + assert_eq!(crc32(b"1234321"), 0xd981751c); + } +} diff --git a/libmaestro/src/hdlc/decoder.rs b/libmaestro/src/hdlc/decoder.rs new file mode 100644 index 0000000..9e9255a --- /dev/null +++ b/libmaestro/src/hdlc/decoder.rs @@ -0,0 +1,328 @@ +use bytes::{Buf, BytesMut}; + +use super::consts; +use super::crc; +use super::varint; +use super::Frame; + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + UnexpectedData, + UnexpectedEndOfFrame, + InvalidChecksum, + InvalidEncoding, + InvalidFrame, + InvalidAddress, + BufferOverflow, +} + +impl From for Error { + fn from(value: varint::DecodeError) -> Self { + match value { + varint::DecodeError::Incomplete => Self::InvalidFrame, + varint::DecodeError::Overflow => Self::InvalidAddress, + } + } +} + + +#[derive(Debug)] +pub struct Decoder { + buf: Vec, + state: (State, EscState), + current_frame_size: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + Discard, + Frame, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EscState { + Normal, + Escape, +} + +impl Decoder { + pub fn new() -> Self { + Self::with_capacity(4096) + } + + pub fn with_capacity(cap: usize) -> Self { + Self { + buf: Vec::with_capacity(cap), + state: (State::Discard, EscState::Normal), + current_frame_size: 0, + } + } + + pub fn process(&mut self, buf: &mut BytesMut) -> Result, Error> { + if buf.is_empty() { + return Ok(None); + } + + loop { + match self.state.0 { + State::Discard => { + // try to find the start of this frame + match find_frame_start(buf) { + // expected: immediate start of frame + Some(0) => { + self.state.0 = State::Frame; + buf.advance(1); + }, + // unexpected: n bytes before start of frame + Some(n) => { + self.state.0 = State::Frame; + buf.advance(n + 1); + return Err(Error::UnexpectedData); + }, + // unexpected: unknown amount of bytes before start of frame + None => { + // check whether the last byte might indicate a start + let n = if buf.last() == Some(&consts::flags::FRAME) { + buf.len() - 1 + } else { + buf.len() + }; + + buf.advance(n); + return Err(Error::UnexpectedData); + }, + } + }, + State::Frame => { + // copy and decode to internal buffer + for (i, b) in buf.iter().copied().enumerate() { + match (b, self.state.1) { + (consts::flags::ESCAPE, EscState::Normal) => { + self.state.1 = EscState::Escape; + }, + (consts::flags::ESCAPE, EscState::Escape) => { + buf.advance(i + 1); + self.reset(); + + return Err(Error::InvalidEncoding); + }, + (consts::flags::FRAME, EscState::Normal) => { + buf.advance(i + 1); + + return self.decode_buffered(); + }, + (consts::flags::FRAME, EscState::Escape) => { + buf.advance(i); + self.reset(); + + return Err(Error::UnexpectedEndOfFrame); + }, + (b, EscState::Normal) => { + self.push_byte(b); + }, + (b, EscState::Escape) => { + self.push_byte(b ^ consts::escape::MASK); + self.state.1 = EscState::Normal; + }, + } + } + + buf.advance(buf.remaining()); + return Ok(None); + }, + } + } + } + + fn decode_buffered(&mut self) -> Result, Error> { + // validate minimum frame size + if self.buf.len() < 6 { + self.reset(); + self.state.0 = State::Frame; // the next frame may already start + return Err(Error::InvalidFrame); + } + + // validate checksum + let crc_actual = crc::crc32(&self.buf[..self.buf.len()-4]); + let crc_expect = self.buf[self.buf.len()-4..].try_into().unwrap(); + let crc_expect = u32::from_le_bytes(crc_expect); + + if crc_expect != crc_actual { + self.reset(); + self.state.0 = State::Frame; // the next frame may already start + return Err(Error::InvalidChecksum); + } + + // check for overflow + if self.current_frame_size > self.buf.len() { + self.reset(); + return Err(Error::BufferOverflow); + } + + // decode address + let (address, n) = varint::decode(&self.buf)?; + + // validate minimum remaining frame size + if self.buf.len() < n + 5 { + self.reset(); + return Err(Error::InvalidFrame); + } + + // get control byte and data + let control = self.buf[n]; + let data = self.buf[n+1..self.buf.len()-4].into(); + + let frame = Frame { + address, + control, + data, + }; + + self.reset(); + Ok(Some(frame)) + } + + fn push_byte(&mut self, byte: u8) { + self.current_frame_size += 1; + + if self.buf.len() < self.buf.capacity() { + self.buf.push(byte); + } + } + + fn reset(&mut self) { + self.buf.clear(); + self.state = (State::Discard, EscState::Normal); + self.current_frame_size = 0; + } +} + +impl Default for Decoder { + fn default() -> Self { + Self::new() + } +} + + +fn find_frame_start(buf: &[u8]) -> Option { + buf.windows(2) + .enumerate() + .find(|(_, b)| b[0] == consts::flags::FRAME && b[1] != consts::flags::FRAME) + .map(|(i, _)| i) +} + + +#[cfg(test)] +mod test { + use bytes::BufMut; + + use super::*; + + #[test] + fn test_find_frame_start() { + let buf = [0x7E, 0x01, 0x02, 0x03]; + assert_eq!(find_frame_start(&buf), Some(0)); + + let buf = [0x03, 0x02, 0x01, 0x00, 0x7E, 0x00, 0x01, 0x02, 0x03]; + assert_eq!(find_frame_start(&buf), Some(4)); + + let buf = [0x03, 0x02, 0x01, 0x00, 0x7E, 0x7E, 0x00, 0x01, 0x02, 0x03]; + assert_eq!(find_frame_start(&buf), Some(5)); + + let buf = [0x03, 0x02, 0x01, 0x00, 0x7E]; + assert_eq!(find_frame_start(&buf), None); + + let buf = [0x03, 0x02, 0x01, 0x00, 0x7E, 0x00]; + assert_eq!(find_frame_start(&buf), Some(4)); + + let buf = [0x7E]; + assert_eq!(find_frame_start(&buf), None); + + let buf = []; + assert_eq!(find_frame_start(&buf), None); + } + + #[test] + fn test_frame_decode() { + let data = [ + // message + 0x7e, 0x06, 0x08, 0x09, 0x03, 0x05, 0x06, 0x07, 0x7d, 0x5d, + 0x7d, 0x5e, 0x7f, 0xff, 0xe6, 0x2d, 0x17, 0xc6, 0x7e, + // and trailing bytes + 0x02, 0x01 + ]; + + let expect = Frame { + address: 0x010203, + control: 0x03, + data: vec![0x05, 0x06, 0x07, 0x7D, 0x7E, 0x7F, 0xFF].into(), + }; + + let mut dec = Decoder::new(); + + // test standard decoding + let mut buf = BytesMut::from(&data[..data.len()-2]); + assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone()))); + assert_eq!(buf.remaining(), 0); + + // test decoding with trailing bytes + let mut buf = BytesMut::from(&data[..data.len()]); + assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone()))); + assert_eq!(buf.remaining(), 2); + + assert_eq!(dec.process(&mut buf), Err(Error::UnexpectedData)); + assert_eq!(buf.remaining(), 0); + + // test partial decoding / re-entrancy + let mut buf = BytesMut::from(&data[..9]); + assert_eq!(dec.process(&mut buf), Ok(None)); + assert_eq!(buf.remaining(), 0); + + assert_eq!(dec.state, (State::Frame, EscState::Escape)); + + let mut buf = BytesMut::from(&data[9..data.len()-2]); + assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone()))); + assert_eq!(buf.remaining(), 0); + + // test decoding of subsequent frames + let mut buf = BytesMut::new(); + buf.put_slice(&data[..data.len()-2]); + buf.put_slice(&data[..]); + + assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone()))); + assert_eq!(buf.remaining(), data.len()); + + assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone()))); + assert_eq!(buf.remaining(), 2); + + // test decoding of cut-off frame / data loss (with frame being too small) + let mut buf = BytesMut::new(); + buf.put_slice(&data[..5]); + buf.put_slice(&data[..]); + + assert_eq!(dec.process(&mut buf), Err(Error::InvalidFrame)); + assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone()))); + assert_eq!(buf.remaining(), 2); + + // test decoding of cut-off frame / data loss (with data being cut off) + let mut buf = BytesMut::new(); + buf.put_slice(&data[..10]); + buf.put_slice(&data[..]); + + assert_eq!(dec.process(&mut buf), Err(Error::InvalidChecksum)); + assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone()))); + assert_eq!(buf.remaining(), 2); + + // test frame flag as escaped byte + let mut buf = BytesMut::from(&data[..10]); + buf.put_slice(&data[..]); + buf[9] = consts::flags::FRAME; + + assert_eq!(dec.process(&mut buf), Err(Error::UnexpectedEndOfFrame)); + assert_eq!(dec.process(&mut buf), Err(Error::UnexpectedData)); + assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone()))); + assert_eq!(buf.remaining(), 2); + + } +} diff --git a/libmaestro/src/hdlc/encoder.rs b/libmaestro/src/hdlc/encoder.rs new file mode 100644 index 0000000..e349196 --- /dev/null +++ b/libmaestro/src/hdlc/encoder.rs @@ -0,0 +1,146 @@ +use bytes::{BufMut, BytesMut}; + +use super::{consts, crc::Crc32, varint, Frame}; + + +struct ByteEscape { + buf: B, +} + +impl ByteEscape { + fn new(buf: B) -> Self { + Self { buf } + } + + fn put_u8(&mut self, byte: u8) { + match byte { + consts::flags::ESCAPE | consts::flags::FRAME => self.buf.put_slice(&[ + consts::flags::ESCAPE, + consts::escape::MASK ^ byte + ]), + _ => self.buf.put_u8(byte), + } + } + + fn put_frame_flag(&mut self) { + self.buf.put_u8(super::consts::flags::FRAME) + } +} + +impl ByteEscape<&mut BytesMut> { + fn reserve(&mut self, additional: usize) -> &mut Self { + self.buf.reserve(additional); + self + } +} + + +struct Encoder { + buf: ByteEscape, + crc: Crc32, +} + +impl Encoder { + fn new(buf: B) -> Self { + Self { + buf: ByteEscape::new(buf), + crc: Crc32::new(), + } + } + + fn flag(&mut self) -> &mut Self { + self.buf.put_frame_flag(); + self + } + + fn put_u8(&mut self, byte: u8) -> &mut Self { + self.crc.put_u8(byte); + self.buf.put_u8(byte); + self + } + + fn put_bytes>(&mut self, bytes: T) -> &mut Self { + for b in bytes.into_iter() { + self.put_u8(b); + } + self + } + + fn finalize(&mut self) { + self.put_bytes(self.crc.value().to_le_bytes()); + self.flag(); + } +} + +impl Encoder<&mut BytesMut> { + fn reserve(&mut self, additional: usize) -> &mut Self { + self.buf.reserve(additional); + self + } +} + + +pub fn encode(buf: &mut BytesMut, frame: &Frame) { + Encoder::new(buf) + .reserve(frame.data.len() + 8) // reserve at least data-size + min-frame-size + .flag() // flag + .put_bytes(varint::encode(frame.address)) // address + .put_u8(frame.control) // control + .put_bytes(frame.data.iter().copied()) // data + .reserve(5) // reserve CRC32 + flag + .finalize() // checksum and flag +} + +pub fn encode_bytes(frame: &Frame) -> BytesMut { + let mut buf = BytesMut::new(); + encode(&mut buf, frame); + buf +} + + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_escape_bytes() { + fn e(src: &[u8]) -> Vec { + let mut dst = Vec::new(); + let mut buf = ByteEscape::new(&mut dst); + + for byte in src { + buf.put_u8(*byte); + } + + dst + } + + assert_eq!(e(&[0x00, 0x00]), [0x00, 0x00]); + assert_eq!(e(&[0x7D]), [0x7D, 0x5D]); + assert_eq!(e(&[0x7E]), [0x7D, 0x5E]); + assert_eq!(e(&[0x01, 0x7D, 0x02]), [0x01, 0x7D, 0x5D, 0x02]); + assert_eq!(e(&[0x01, 0x7E, 0x02]), [0x01, 0x7D, 0x5E, 0x02]); + assert_eq!(e(&[0x7D, 0x7E]), [0x7D, 0x5D, 0x7D, 0x5E]); + assert_eq!(e(&[0x7F, 0x5D, 0x7E]), [0x7F, 0x5D, 0x7D, 0x5E]); + } + + #[test] + fn test_encode() { + assert_eq!([ + 0x7e, 0x06, 0x08, 0x09, 0x03, 0x8b, 0x3b, 0xf7, 0x42, 0x7e, + ], &encode_bytes(&Frame { + address: 0x010203, + control: 0x03, + data: vec![].into(), + })[..]); + + assert_eq!([ + 0x7e, 0x06, 0x08, 0x09, 0x03, 0x05, 0x06, 0x07, 0x7d, 0x5d, + 0x7d, 0x5e, 0x7f, 0xff, 0xe6, 0x2d, 0x17, 0xc6, 0x7e, + ], &encode_bytes(&Frame { + address: 0x010203, + control: 0x03, + data: vec![0x05, 0x06, 0x07, 0x7d, 0x7e, 0x7f, 0xff].into(), + })[..]); + } +} diff --git a/libmaestro/src/hdlc/mod.rs b/libmaestro/src/hdlc/mod.rs new file mode 100644 index 0000000..9ceeb51 --- /dev/null +++ b/libmaestro/src/hdlc/mod.rs @@ -0,0 +1,34 @@ +//! High-level Data Link Control (HDLC) support library. + +pub mod codec; +pub mod consts; +pub mod crc; +pub mod decoder; +pub mod encoder; +pub mod varint; + +pub use codec::Codec; + +use bytes::BytesMut; + + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub address: u32, + pub control: u8, + pub data: Box<[u8]>, +} + +impl Frame { + pub fn decode(buf: &mut BytesMut) -> Result, decoder::Error> { + decoder::Decoder::new().process(buf) + } + + pub fn encode(&self, buf: &mut BytesMut) { + encoder::encode(buf, self) + } + + pub fn encode_bytes(&self) -> BytesMut { + encoder::encode_bytes(self) + } +} diff --git a/libmaestro/src/hdlc/varint.rs b/libmaestro/src/hdlc/varint.rs new file mode 100644 index 0000000..e860412 --- /dev/null +++ b/libmaestro/src/hdlc/varint.rs @@ -0,0 +1,154 @@ +//! Support for variable length integer encoding as used in the HDLC frame encoding. + +use arrayvec::ArrayVec; + + +pub fn decode<'a, S: IntoIterator>(src: S) -> Result<(u32, usize), DecodeError> { + let mut address = 0; + + for (i, b) in src.into_iter().copied().enumerate() { + address |= ((b >> 1) as u64) << (i * 7); + + if address > u32::MAX as u64 { + Err(DecodeError::Overflow)?; + } + + if b & 0x01 == 0x01 { + return Ok((address as u32, i + 1)); + } + } + + Err(DecodeError::Incomplete) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecodeError { + Incomplete, + Overflow, +} + + +pub fn encode(num: u32) -> Encode { + Encode { num, done: false } +} + +pub fn encode_vec(num: u32) -> ArrayVec { + encode(num).collect() +} + +pub struct Encode { + num: u32, + done: bool, +} + +impl Iterator for Encode { + type Item = u8; + + fn next(&mut self) -> Option { + if (self.num >> 7) != 0 { + let b = ((self.num & 0x7F) as u8) << 1; + self.num >>= 7; + + Some(b) + } else if !self.done { + let b = (((self.num & 0x7F) as u8) << 1) | 1; + self.done = true; + + Some(b) + } else { + None + } + } +} + + +pub const fn num_bytes(value: u32) -> usize { + if value == 0 { + 1 + } else { + (u32::BITS - value.leading_zeros()).div_ceil(7) as _ + } +} + + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_decode() { + assert_eq!(decode(&[0x01]).unwrap(), (0x00, 1)); + assert_eq!(decode(&[0x00, 0x00, 0x00, 0x01]).unwrap(), (0x00, 4)); + assert_eq!(decode(&[0x11, 0x00]).unwrap(), (0x0008, 1)); + assert_eq!(decode(&[0x10, 0x21]).unwrap(), (0x0808, 2)); + + assert_eq!(decode(&[0x01]).unwrap(), (0x00, 1)); + assert_eq!(decode(&[0x03]).unwrap(), (0x01, 1)); + + assert_eq!(decode(&[0xff]).unwrap(), (0x7f, 1)); + assert_eq!(decode(&[0x00, 0x03]).unwrap(), (0x80, 2)); + + assert_eq!(decode(&[0xfe, 0xff]).unwrap(), (0x3fff, 2)); + assert_eq!(decode(&[0x00, 0x00, 0x03]).unwrap(), (0x4000, 3)); + + assert_eq!(decode(&[0xfe, 0xfe, 0xff]).unwrap(), (0x1f_ffff, 3)); + assert_eq!(decode(&[0x00, 0x00, 0x00, 0x03]).unwrap(), (0x20_0000, 4)); + + assert_eq!(decode(&[0xfe, 0xfe, 0xfe, 0xff]).unwrap(), (0x0fff_ffff, 4)); + assert_eq!(decode(&[0x00, 0x00, 0x00, 0x00, 0x03]).unwrap(), (0x1000_0000, 5)); + + assert_eq!(decode(&[0xfe, 0x03]).unwrap(), (u8::MAX as _, 2)); + assert_eq!(decode(&[0xfe, 0xfe, 0x07]).unwrap(), (u16::MAX as _, 3)); + assert_eq!(decode(&[0xfe, 0xfe, 0xfe, 0xfe, 0x1f]).unwrap(), (u32::MAX, 5)); + + assert_eq!(decode(&[0xFE]), Err(DecodeError::Incomplete)); + assert_eq!(decode(&[0xFE, 0xFE, 0xFE, 0xFE, 0xFF]), Err(DecodeError::Overflow)); + } + + #[test] + fn test_encode() { + assert_eq!(encode_vec(0x01234)[..], [0x68, 0x49]); + assert_eq!(encode_vec(0x87654)[..], [0xa8, 0xd8, 0x43]); + + assert_eq!(encode_vec(0x00)[..], [0x01]); + assert_eq!(encode_vec(0x01)[..], [0x03]); + + assert_eq!(encode_vec(0x7f)[..], [0xff]); + assert_eq!(encode_vec(0x80)[..], [0x00, 0x03]); + + assert_eq!(encode_vec(0x3fff)[..], [0xfe, 0xff]); + assert_eq!(encode_vec(0x4000)[..], [0x00, 0x00, 0x03]); + + assert_eq!(encode_vec(0x1f_ffff)[..], [0xfe, 0xfe, 0xff]); + assert_eq!(encode_vec(0x20_0000)[..], [0x00, 0x00, 0x00, 0x03]); + + assert_eq!(encode_vec(0x0fff_ffff)[..], [0xfe, 0xfe, 0xfe, 0xff]); + assert_eq!(encode_vec(0x1000_0000)[..], [0x00, 0x00, 0x00, 0x00, 0x03]); + + assert_eq!(encode_vec(u8::MAX as _)[..], [0xfe, 0x03]); + assert_eq!(encode_vec(u16::MAX as _)[..], [0xfe, 0xfe, 0x07]); + assert_eq!(encode_vec(u32::MAX)[..], [0xfe, 0xfe, 0xfe, 0xfe, 0x1f]); + } + + #[test] + fn test_num_bytes() { + assert_eq!(num_bytes(0x00), 1); + assert_eq!(num_bytes(0x01), 1); + + assert_eq!(num_bytes(0x7f), 1); + assert_eq!(num_bytes(0x80), 2); + + assert_eq!(num_bytes(0x3fff), 2); + assert_eq!(num_bytes(0x4000), 3); + + assert_eq!(num_bytes(0x1f_ffff), 3); + assert_eq!(num_bytes(0x20_0000), 4); + + assert_eq!(num_bytes(0x0fff_ffff), 4); + assert_eq!(num_bytes(0x1000_0000), 5); + + assert_eq!(num_bytes(u8::MAX as _), 2); + assert_eq!(num_bytes(u16::MAX as _), 3); + assert_eq!(num_bytes(u32::MAX), 5); + } +} diff --git a/libmaestro/src/lib.rs b/libmaestro/src/lib.rs new file mode 100644 index 0000000..0af69bd --- /dev/null +++ b/libmaestro/src/lib.rs @@ -0,0 +1,15 @@ +//! Library for the Maestro protocol used to change settings (ANC, equalizer, +//! etc.) on the Google Pixel Buds Pro. Might support other Pixel Buds, might +//! not. + +use uuid::{uuid, Uuid}; + +/// UUID under which the Maestro protocol is advertised. +/// +/// Defined as `25e97ff7-24ce-4c4c-8951-f764a708f7b5`. +pub const UUID: Uuid = uuid!("25e97ff7-24ce-4c4c-8951-f764a708f7b5"); + +pub mod hdlc; +pub mod protocol; +pub mod pwrpc; +pub mod service; diff --git a/libmaestro/src/protocol/addr.rs b/libmaestro/src/protocol/addr.rs new file mode 100644 index 0000000..90a6d31 --- /dev/null +++ b/libmaestro/src/protocol/addr.rs @@ -0,0 +1,115 @@ +use num_enum::{FromPrimitive, IntoPrimitive}; + + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)] +pub enum Peer { + Unknown = 0, + Host = 1, + Case = 2, + LeftBtCore = 3, + RightBtCore = 4, + LeftSensorHub = 5, + RightSensorHub = 6, + LeftSpiBridge = 7, + RightSpiBridge = 8, + DebugApp = 9, + MaestroA = 10, + LeftTahiti = 11, + RightTahiti = 12, + MaestroB = 13, + + #[num_enum(catch_all)] + Unrecognized(u8) = 0xff, +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Address { + value: u32, +} + +impl Address { + pub fn from_value(value: u32) -> Self { + Address { value } + } + + pub fn from_peers(source: Peer, target: Peer) -> Self { + let source: u8 = source.into(); + let target: u8 = target.into(); + + Self::from_value(((source as u32 & 0xf) << 6) | ((target as u32 & 0xf) << 10)) + } + + pub fn value(&self) -> u32 { + self.value + } + + pub fn source(&self) -> Peer { + Peer::from_primitive(((self.value >> 6) & 0x0f) as u8) + } + + pub fn target(&self) -> Peer { + Peer::from_primitive(((self.value >> 10) & 0x0f) as u8) + } + + pub fn swap(&self) -> Self { + Self::from_peers(self.target(), self.source()) + } + + pub fn channel_id(&self) -> Option { + let source = self.source(); + let target = self.target(); + + if source == Peer::MaestroA || source == Peer::MaestroB { + channel_id(source, target) + } else { + channel_id(target, source) + } + } +} + +impl From for Address { + fn from(value: u32) -> Self { + Self::from_value(value) + } +} + +impl From<(Peer, Peer)> for Address { + fn from(peers: (Peer, Peer)) -> Self { + Self::from_peers(peers.0, peers.1) + } +} + + +pub fn channel_id(local: Peer, remote: Peer) -> Option { + match (local, remote) { + (Peer::MaestroA, Peer::Case) => Some(18), + (Peer::MaestroA, Peer::LeftBtCore) => Some(19), + (Peer::MaestroA, Peer::LeftSensorHub) => Some(20), + (Peer::MaestroA, Peer::RightBtCore) => Some(21), + (Peer::MaestroA, Peer::RightSensorHub) => Some(22), + (Peer::MaestroB, Peer::Case) => Some(23), + (Peer::MaestroB, Peer::LeftBtCore) => Some(24), + (Peer::MaestroB, Peer::LeftSensorHub) => Some(25), + (Peer::MaestroB, Peer::RightBtCore) => Some(26), + (Peer::MaestroB, Peer::RightSensorHub) => Some(27), + (_, _) => None, + } +} + +pub fn address_for_channel(channel: u32) -> Option
{ + match channel { + 18 => Some(Address::from_peers(Peer::MaestroA, Peer::Case)), + 19 => Some(Address::from_peers(Peer::MaestroA, Peer::LeftBtCore)), + 20 => Some(Address::from_peers(Peer::MaestroA, Peer::LeftSensorHub)), + 21 => Some(Address::from_peers(Peer::MaestroA, Peer::RightBtCore)), + 22 => Some(Address::from_peers(Peer::MaestroA, Peer::RightSensorHub)), + 23 => Some(Address::from_peers(Peer::MaestroB, Peer::Case)), + 24 => Some(Address::from_peers(Peer::MaestroB, Peer::LeftBtCore)), + 25 => Some(Address::from_peers(Peer::MaestroB, Peer::LeftSensorHub)), + 26 => Some(Address::from_peers(Peer::MaestroB, Peer::RightBtCore)), + 27 => Some(Address::from_peers(Peer::MaestroB, Peer::RightSensorHub)), + _ => None, + } +} diff --git a/libmaestro/src/protocol/codec.rs b/libmaestro/src/protocol/codec.rs new file mode 100644 index 0000000..3445d24 --- /dev/null +++ b/libmaestro/src/protocol/codec.rs @@ -0,0 +1,81 @@ +use bytes::BytesMut; + +use prost::Message; + +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_util::codec::{Decoder, Framed, Encoder}; + +use crate::pwrpc::types::RpcPacket; +use crate::hdlc; + +use super::addr; + + +pub struct Codec { + hdlc: hdlc::Codec, +} + +impl Codec { + pub fn new() -> Self { + Self { + hdlc: hdlc::Codec::new(), + } + } + + pub fn wrap(self, io: T) -> Framed + where + T: AsyncRead + AsyncWrite, + { + Framed::with_capacity(io, self, 4096 as _) + } +} + +impl Default for Codec { + fn default() -> Self { + Self::new() + } +} + +impl Decoder for Codec { + type Item = RpcPacket; + type Error = std::io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + match self.hdlc.decode(src)? { + Some(frame) => { + if frame.control != 0x03 { + tracing::warn!("unexpected control type: {}", frame.control); + return Ok(None); + } + + let packet = RpcPacket::decode(&frame.data[..])?; + Ok(Some(packet)) + } + None => Ok(None), + } + } +} + +impl Encoder<&RpcPacket> for Codec { + type Error = std::io::Error; + + fn encode(&mut self, packet: &RpcPacket, dst: &mut BytesMut) -> Result<(), Self::Error> { + let address = addr::address_for_channel(packet.channel_id).unwrap(); + + let frame = hdlc::Frame { + address: address.value(), + control: 0x03, + data: packet.encode_to_vec().into(), // TODO: can we avoid these allocations? + }; + + self.hdlc.encode(&frame, dst) + } +} + +impl Encoder for Codec { + type Error = std::io::Error; + + fn encode(&mut self, packet: RpcPacket, dst: &mut BytesMut) -> Result<(), Self::Error> { + self.encode(&packet, dst) + } +} diff --git a/libmaestro/src/protocol/mod.rs b/libmaestro/src/protocol/mod.rs new file mode 100644 index 0000000..b4c61e6 --- /dev/null +++ b/libmaestro/src/protocol/mod.rs @@ -0,0 +1,7 @@ +pub mod addr; +pub mod codec; +pub mod utils; + +pub mod types { + include!(concat!(env!("OUT_DIR"), "/maestro_pw.rs")); +} diff --git a/libmaestro/src/protocol/utils.rs b/libmaestro/src/protocol/utils.rs new file mode 100644 index 0000000..b1ee51f --- /dev/null +++ b/libmaestro/src/protocol/utils.rs @@ -0,0 +1,73 @@ +use crate::pwrpc::Error; +use crate::pwrpc::client::{Client, Request, UnaryResponse, ClientHandle}; +use crate::pwrpc::id::PathRef; +use crate::pwrpc::types::RpcPacket; + +use super::addr; +use super::addr::Peer; +use super::types::SoftwareInfo; + + +pub async fn resolve_channel(client: &mut Client) -> Result +where + S: futures::Sink, + S: futures::Stream> + Unpin, + Error: From, + Error: From, +{ + tracing::trace!("resolving channel"); + + let channels = ( + addr::channel_id(Peer::MaestroA, Peer::Case).unwrap(), + addr::channel_id(Peer::MaestroA, Peer::LeftBtCore).unwrap(), + addr::channel_id(Peer::MaestroA, Peer::RightBtCore).unwrap(), + addr::channel_id(Peer::MaestroB, Peer::Case).unwrap(), + addr::channel_id(Peer::MaestroB, Peer::LeftBtCore).unwrap(), + addr::channel_id(Peer::MaestroB, Peer::RightBtCore).unwrap(), + ); + + let tasks = ( + try_open_channel(client.handle(), channels.0), + try_open_channel(client.handle(), channels.1), + try_open_channel(client.handle(), channels.2), + try_open_channel(client.handle(), channels.3), + try_open_channel(client.handle(), channels.4), + try_open_channel(client.handle(), channels.5), + ); + + let channel = tokio::select! { + // Ensure that the open() calls are registered before we start running + // the client. + biased; + + res = tasks.0 => { res? }, + res = tasks.1 => { res? }, + res = tasks.2 => { res? }, + res = tasks.3 => { res? }, + res = tasks.4 => { res? }, + res = tasks.5 => { res? }, + res = client.run() => { res?; return Err(Error::aborted("client terminated")) } + }; + + tracing::trace!(channel=channel, "channel resolved"); + Ok(channel) +} + +async fn try_open_channel(mut handle: ClientHandle, channel_id: u32) -> Result { + let path = PathRef::new("maestro_pw.Maestro/GetSoftwareInfo"); + let service_id = path.service().hash(); + let method_id = path.method().hash(); + + let req = Request { + channel_id, + service_id, + method_id, + call_id: 0xffffffff, + message: (), + }; + + let mut rsp: UnaryResponse = handle.open_unary(req)?; + + rsp.result().await?; + Ok(channel_id) +} diff --git a/libmaestro/src/pwrpc/client.rs b/libmaestro/src/pwrpc/client.rs new file mode 100644 index 0000000..703f223 --- /dev/null +++ b/libmaestro/src/pwrpc/client.rs @@ -0,0 +1,960 @@ +use std::pin::Pin; +use std::task::Poll; + +use futures::{Sink, SinkExt, Stream, StreamExt}; +use futures::channel::mpsc; +use futures::stream::{SplitSink, SplitStream, FusedStream}; + +use prost::Message; + +use super::id::Path; +use super::status::{Status, Error}; +use super::types::{RpcType, RpcPacket, PacketType}; + + +#[derive(Debug)] +pub struct Client { + /// Stream for lower-level transport. + io_rx: SplitStream, + + /// Sink for lower-level transport. + io_tx: SplitSink, + + /// Queue receiver for requests to be processed and sent by us. + queue_rx: mpsc::UnboundedReceiver, + + /// Queue sender for requests to be processed by us. Counter-part for + /// `queue_rx`, used by callers via `ClientHandle` to initiate new calls. + queue_tx: mpsc::UnboundedSender, + + /// Pending RPC calls, waiting for a response. + pending: Vec, +} + +impl Client +where + S: Sink, + S: Stream> + Unpin, + Error: From, + Error: From, +{ + pub fn new(stream: S) -> Client { + let (io_tx, io_rx) = stream.split(); + let (queue_tx, queue_rx) = mpsc::unbounded(); + + Client { + io_rx, + io_tx, + queue_rx, + queue_tx, + pending: Vec::new(), + } + } + + pub fn handle(&self) -> ClientHandle { + ClientHandle { + queue_tx: self.queue_tx.clone(), + } + } + + pub async fn run(&mut self) -> Result<(), Error> { + // Process the request queue first in case we are trying to catch some + // early RPC responses via open() calls. + while let Ok(Some(request)) = self.queue_rx.try_next() { + self.process_request(request).await?; + } + + loop { + tokio::select! { + packet = self.io_rx.next() => { + let packet = packet + .ok_or_else(|| Error::aborted("underlying IO stream closed"))??; + + self.process_packet(packet).await?; + }, + request = self.queue_rx.next() => { + // SAFETY: We hold both sender and receiver parts and are + // the only ones allowed to close this queue. Therefore, it + // will always be open here. + let request = request.expect("request queue closed unexpectedly"); + + self.process_request(request).await?; + }, + } + } + } + + pub async fn terminate(&mut self) -> Result<(), Error> { + tracing::trace!("terminating client"); + + // Collect messages to be sent instead of directly sending them. We + // process infallible (local) operations first, before we try to + // communicate with the RPC peer, which is fallible. + let mut send = Vec::new(); + + // Close our request queue. + self.queue_rx.close(); + + // Process all pending requests. Abort requests for new calls and + // send/forward any errors. + // + // SAFETY: try_next() can only return an error when the channel has not + // been closed yet. + while let Some(msg) = self.queue_rx.try_next().unwrap() { + match msg { + CallRequest::New { sender, .. } => { + // Drop new requests. Instead, notify caller with status 'aborted'. + let update = CallUpdate::Error { status: Status::Aborted }; + let _ = sender.unbounded_send(update); + sender.close_channel(); + }, + CallRequest::Error { uid, code, tx } => { + // Process error requests as normal: Send error message to + // peer, remove and complete call. + if let Some(mut call) = self.find_and_remove_call(uid) { + call.complete_with_error(code).await; + if tx { + send.push((uid, code)); + } + } + }, + } + } + + // Cancel all pending RPCs and remove them from the list. + for call in &mut self.pending { + call.complete_with_error(Status::Aborted).await; + send.push((call.uid, Status::Cancelled)); + } + self.pending.clear(); + + // Define functions because async try-catch blocks aren't a thing yet... + async fn do_send(client: &mut Client, send: Vec<(CallUid, Status)>) -> Result<(), Error> + where + S: Sink, + S: Stream> + Unpin, + Error: From, + Error: From, + { + for (uid, code) in send { + client.send_client_error(uid, code).await?; + } + Ok(()) + } + + async fn do_close(client: &mut Client) -> Result<(), Error> + where + S: Sink, + S: Stream> + Unpin, + Error: From, + Error: From, + { + client.io_tx.close().await?; + Ok(()) + } + + // Try to send cancel/error messages. + let res_send = do_send(self, send).await; + + // Try to close the transport. + let res_close = do_close(self).await; + + // Return the first error. + res_send?; + res_close + } + + async fn process_packet(&mut self, packet: RpcPacket) -> Result<(), Error> { + tracing::trace!( + "received packet: type=0x{:02x}, channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}", + packet.r#type, packet.channel_id, packet.service_id, packet.method_id, packet.call_id + ); + + let ty = packet.r#type; + let ty = PacketType::try_from(ty); + + match ty { + Ok(PacketType::Response) => { + self.rpc_complete(packet).await + }, + Ok(PacketType::ServerError) => { + self.rpc_complete_with_error(packet).await + }, + Ok(PacketType::ServerStream) => { + self.rpc_stream_push(packet).await? + }, + Ok(_) => { + tracing::error!( + "unsupported packet type: type=0x{:02x}, channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}", + packet.r#type, packet.channel_id, packet.service_id, packet.method_id, packet.call_id + ); + }, + Err(_) => { + tracing::error!( + "unknown packet type: type=0x{:02x}, channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}", + packet.r#type, packet.channel_id, packet.service_id, packet.method_id, packet.call_id + ); + }, + } + + Ok(()) + } + + async fn rpc_complete(&mut self, packet: RpcPacket) { + let uid = CallUid::from_packet(&packet); + let call = self.find_and_remove_call(uid); + + match call { + Some(mut call) => { // pending call found, complete rpc + tracing::trace!( + "completing rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}", + packet.channel_id, packet.service_id, packet.method_id, packet.call_id + ); + + if packet.status != 0 { + tracing::warn!( + "completing rpc with non-zero status: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, status={}", + packet.channel_id, packet.service_id, packet.method_id, packet.call_id, packet.status + ); + } + + let status = Status::from(packet.status); + call.complete(packet.payload, status).await; + }, + None => { // no pending call found, silently drop packet + tracing::debug!( + "received response for non-pending rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}", + packet.channel_id, packet.service_id, packet.method_id, packet.call_id + ); + }, + } + } + + async fn rpc_complete_with_error(&mut self, packet: RpcPacket) { + let uid = CallUid::from_packet(&packet); + let call = self.find_and_remove_call(uid); + + match call { + Some(mut call) => { // pending call found, complete rpc with error + tracing::trace!( + "completing rpc with error: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, status={}", + packet.channel_id, packet.service_id, packet.method_id, packet.call_id, packet.status + ); + + let status = Status::from(packet.status); + call.complete_with_error(status).await; + }, + None => { // no pending call found, silently drop packet + tracing::debug!( + "received error for non-pending rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, status={}", + packet.channel_id, packet.service_id, packet.method_id, packet.call_id, packet.status + ); + }, + } + } + + async fn rpc_stream_push(&mut self, packet: RpcPacket) -> Result<(), Error> { + let uid = CallUid::from_packet(&packet); + let call = self.find_call_mut(uid); + + match call { + Some(call) => { // pending call found, forward packet to caller + tracing::trace!( + "pushing server stream packet to caller: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}", + packet.channel_id, packet.service_id, packet.method_id, packet.call_id + ); + + if call.ty.has_server_stream() { // packet was expected, forward it + call.push_item(packet.payload).await; + } else { // this type of rpc doesn't expect streaming packets from the server + // SAFETY: We are the only ones that can add, remove, or + // otherwise modify items in-between the above find + // operation and this one as we have the lock. + let mut call = self.find_and_remove_call(uid).unwrap(); + + tracing::warn!( + "received stream packet for non-stream rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}", + packet.channel_id, packet.service_id, packet.method_id, packet.call_id + ); + + call.complete_with_error(Status::InvalidArgument).await; + self.send_client_error(uid, Status::InvalidArgument).await?; + } + }, + None => { // no pending call found, try to notify server + tracing::debug!( + "received stream packet for non-pending rpc: service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}", + packet.service_id, packet.method_id, packet.call_id + ); + + self.send_client_error(uid, Status::FailedPrecondition).await?; + }, + } + + Ok(()) + } + + async fn process_request(&mut self, request: CallRequest) -> Result<(), Error> { + match request { + CallRequest::New { ty, uid, payload, sender, tx } => { + let call = Call { ty, uid, sender }; + + let packet = RpcPacket { + r#type: PacketType::Request.into(), + channel_id: uid.channel, + service_id: uid.service, + method_id: uid.method, + payload, + status: Status::Ok as _, + call_id: uid.call, + }; + + let action = if tx { "starting" } else { "opening" }; + tracing::trace!( + "{} rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}", + action, packet.channel_id, packet.service_id, packet.method_id, packet.call_id, + ); + + self.pending.push(call); + if tx { + self.send(packet).await?; + } + + Ok(()) + }, + CallRequest::Error { uid, code, tx } => { + match self.find_and_remove_call(uid) { + Some(mut call) => { + tracing::trace!( + "cancelling active rpc with code: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, code={}", + uid.channel, uid.service, uid.method, uid.call, code as u32, + ); + + call.complete_with_error(code).await; + if tx { + self.send_client_error(uid, code).await?; + } + + Ok(()) + }, + None => { + tracing::trace!( + "received error request for non-pending rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, code={}", + uid.channel, uid.service, uid.method, uid.call, code as u32, + ); + Ok(()) + }, + } + }, + } + } + + fn find_and_remove_call(&mut self, uid: CallUid) -> Option { + let index = self.pending.iter().position(|call| call.uid == uid); + + match index { + Some(index) => Some(self.pending.remove(index)), + None => None, + } + } + + fn find_call_mut(&mut self, uid: CallUid) -> Option<&mut Call> { + self.pending.iter_mut().find(|call| call.uid == uid) + } + + async fn send_client_error(&mut self, uid: CallUid, status: Status) -> Result<(), Error> { + let status: u32 = status.into(); + + tracing::trace!( + "sending client error packet: status={}, channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}", + status, uid.channel, uid.service, uid.method, uid.call, + ); + + let error_packet = RpcPacket { + r#type: PacketType::ClientError as _, + channel_id: uid.channel, + service_id: uid.service, + method_id: uid.method, + call_id: uid.call, + payload: Vec::new(), + status, + }; + + self.send(error_packet).await + } + + async fn send(&mut self, packet: RpcPacket) -> Result<(), Error> { + self.io_tx.send(packet).await?; + Ok(()) + } +} + + +#[derive(Debug, Clone)] +pub struct ClientHandle { + queue_tx: mpsc::UnboundedSender, +} + +impl ClientHandle { + pub fn call_unary(&mut self, request: Request) -> Result, Error> + where + M1: Message, + M2: Message + Default, + { + let handle = self.call(RpcType::Unary, request)?; + + let response = UnaryResponse { + maker: std::marker::PhantomData, + handle, + }; + + Ok(response) + } + + pub fn call_server_stream(&mut self, request: Request) -> Result, Error> + where + M1: Message, + M2: Message + Default, + { + let handle = self.call(RpcType::ServerStream, request)?; + + let stream = StreamResponse { + marker: std::marker::PhantomData, + handle, + }; + + Ok(stream) + } + + fn call(&mut self, ty: RpcType, request: Request) -> Result + where + M: Message, + { + let (sender, receiver) = mpsc::unbounded(); + + let uid = CallUid { + channel: request.channel_id, + service: request.service_id, + method: request.method_id, + call: request.call_id, + }; + + let payload = request.message.encode_to_vec(); + let queue_tx = self.queue_tx.clone(); + + let request = CallRequest::New { ty, uid, payload, sender, tx: true }; + let handle = CallHandle { uid, queue_tx, receiver, cancel_on_drop: true }; + + self.queue_tx.unbounded_send(request) + .map_err(|_| Error::aborted("the channel has been closed, no new calls are allowed"))?; + + Ok(handle) + } + + pub fn open_unary(&mut self, request: Request<()>) -> Result, Error> + where + M: Message + Default, + { + let handle = self.open(RpcType::Unary, request)?; + + let response = UnaryResponse { + maker: std::marker::PhantomData, + handle, + }; + + Ok(response) + } + + pub fn open_server_stream(&mut self, request: Request<()>) -> Result, Error> + where + M: Message + Default, + { + let handle = self.open(RpcType::ServerStream, request)?; + + let stream = StreamResponse { + marker: std::marker::PhantomData, + handle, + }; + + Ok(stream) + } + + fn open(&mut self, ty: RpcType, request: Request) -> Result + where + M: Message, + { + let (sender, receiver) = mpsc::unbounded(); + + let uid = CallUid { + channel: request.channel_id, + service: request.service_id, + method: request.method_id, + call: request.call_id, + }; + + let payload = Vec::new(); + let queue_tx = self.queue_tx.clone(); + + let request = CallRequest::New { ty, uid, payload, sender, tx: false }; + let handle = CallHandle { uid, queue_tx, receiver, cancel_on_drop: false }; + + self.queue_tx.unbounded_send(request) + .map_err(|_| Error::aborted("the channel has been closed, no new calls are allowed"))?; + + Ok(handle) + } +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CallUid { + channel: u32, + service: u32, + method: u32, + call: u32, +} + +impl CallUid { + fn from_packet(packet: &RpcPacket) -> Self { + Self { + channel: packet.channel_id, + service: packet.service_id, + method: packet.method_id, + call: packet.call_id + } + } +} + + +#[derive(Debug)] +enum CallRequest { + New { + ty: RpcType, + uid: CallUid, + payload: Vec, + sender: mpsc::UnboundedSender, + tx: bool, + }, + Error { + uid: CallUid, + code: Status, + tx: bool, + }, +} + + +#[derive(Debug)] +enum CallUpdate { + Complete { + data: Vec, + status: Status, + }, + StreamItem { + data: Vec, + }, + Error { + status: Status, + } +} + + +#[derive(Debug)] +struct Call { + ty: RpcType, + uid: CallUid, + sender: mpsc::UnboundedSender, +} + +impl Call { + pub async fn complete(&mut self, payload: Vec, status: Status) { + let update = CallUpdate::Complete { data: payload, status }; + self.push_update(update).await; + self.sender.close_channel(); + } + + pub async fn complete_with_error(&mut self, status: Status) { + let update = CallUpdate::Error { status }; + self.push_update(update).await; + self.sender.close_channel(); + } + + pub async fn push_item(&mut self, payload: Vec) { + let update = CallUpdate::StreamItem { data: payload }; + self.push_update(update).await; + } + + async fn push_update(&mut self, update: CallUpdate) { + if let Err(e) = self.sender.unbounded_send(update) { + let update = e.into_inner(); + + match update { + CallUpdate::Complete { .. } => { + tracing::warn!( + "cannot send call update, caller is gone: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, update=complete", + self.uid.channel, self.uid.service, self.uid.method, self.uid.call, + ) + }, + CallUpdate::StreamItem { .. } => { + tracing::warn!( + "cannot send call update, caller is gone: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, update=stream", + self.uid.channel, self.uid.service, self.uid.method, self.uid.call, + ) + }, + CallUpdate::Error { status } => { + let code: u32 = status.into(); + + tracing::trace!( + "cannot send call update, caller is gone: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, update=error, error={}", + self.uid.channel, self.uid.service, self.uid.method, self.uid.call, code, + ) + }, + } + } + } +} + +impl Drop for Call { + fn drop(&mut self) { + // Notify caller that call has been aborted if the call has not been + // completed yet. Ignore errors. + if !self.sender.is_closed() { + let update = CallUpdate::Error { status: Status::Aborted }; + let _ = self.sender.unbounded_send(update); + self.sender.close_channel(); + } + } +} + + +struct CallHandle { + uid: CallUid, + queue_tx: mpsc::UnboundedSender, + receiver: mpsc::UnboundedReceiver, + cancel_on_drop: bool, +} + +impl CallHandle { + fn is_complete(&self) -> bool { + self.queue_tx.is_closed() + } + + fn error(&mut self, code: Status, tx: bool) -> bool { + let request = CallRequest::Error { uid: self.uid, code, tx }; + let ok = self.queue_tx.unbounded_send(request).is_ok(); + + // Sending an error will complete the RPC. Disconnect our queue end to + // prevent more errors/cancel-requests to be sent. + self.queue_tx.disconnect(); + + ok + } + + fn abandon(&mut self) -> bool { + self.error(Status::Cancelled, false) + } + + fn cancel_on_drop(&mut self, cancel: bool) { + self.cancel_on_drop = cancel + } + + fn cancel(&mut self) -> bool { + self.error(Status::Cancelled, true) + } + + async fn cancel_and_wait(&mut self) -> Result<(), Error> { + if !self.cancel() { + return Ok(()) + } + + loop { + match self.receiver.next().await { + Some(CallUpdate::StreamItem { .. }) => { + continue + }, + Some(CallUpdate::Complete { .. }) => { + return Ok(()) + }, + Some(CallUpdate::Error { status: Status::Cancelled }) => { + return Ok(()) + }, + Some(CallUpdate::Error { status }) => { + return Err(Error::from(status)) + }, + None => { + return Ok(()) + }, + } + } + } +} + +impl Drop for CallHandle { + fn drop(&mut self) { + if self.cancel_on_drop { + self.cancel(); + } else { + self.abandon(); + } + } +} + + +pub struct Request { + pub channel_id: u32, + pub service_id: u32, + pub method_id: u32, + pub call_id: u32, + pub message: M, +} + + +pub struct UnaryResponse { + maker: std::marker::PhantomData, + handle: CallHandle, +} + +impl UnaryResponse +where + M: Message + Default, +{ + pub async fn result(&mut self) -> Result { + let update = match self.handle.receiver.next().await { + Some(update) => update, + None => return Err(Error::resource_exhausted("cannot fetch result() multiple times")), + }; + + let data = match update { + CallUpdate::Complete { data, status: Status::Ok } => data, + CallUpdate::Complete { status, .. } => return Err(Error::from(status)), + CallUpdate::Error { status } => return Err(Error::from(status)), + CallUpdate::StreamItem { .. } => unreachable!("received stream update on unary rpc"), + }; + + self.handle.queue_tx.disconnect(); + + let message = M::decode(&data[..])?; + Ok(message) + } + + pub fn abandon(&mut self) -> bool { + self.handle.abandon() + } + + pub fn cancel_on_drop(&mut self, cacnel: bool) { + self.handle.cancel_on_drop(cacnel) + } + + pub fn cancel(&mut self) -> bool { + self.handle.cancel() + } + + pub async fn cancel_and_wait(&mut self) -> Result<(), Error> { + self.handle.cancel_and_wait().await + } + + pub fn is_complete(&self) -> bool { + self.handle.is_complete() + } +} + + +pub struct StreamResponse { + marker: std::marker::PhantomData, + handle: CallHandle, +} + +impl StreamResponse +where + M: Message + Default, +{ + pub fn stream(&mut self) -> ServerStream<'_, M> { + ServerStream { + marker: std::marker::PhantomData, + handle: &mut self.handle, + } + } + + pub fn abandon(&mut self) -> bool { + self.handle.abandon() + } + + pub fn cancel_on_drop(&mut self, cacnel: bool) { + self.handle.cancel_on_drop(cacnel) + } + + pub fn cancel(&mut self) -> bool { + self.handle.cancel() + } + + pub async fn cancel_and_wait(&mut self) -> Result<(), Error> { + self.handle.cancel_and_wait().await + } + + pub fn is_complete(&self) -> bool { + self.handle.is_complete() + } +} + + +pub struct ServerStream<'a, M> { + marker: std::marker::PhantomData<&'a mut M>, + handle: &'a mut CallHandle, +} + +impl Stream for ServerStream<'_, M> +where + M: Message + Default, +{ + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll> { + let update = match Pin::new(&mut self.handle.receiver).poll_next(cx) { + Poll::Ready(Some(update)) => update, + Poll::Ready(None) => return Poll::Ready(None), + Poll::Pending => return Poll::Pending, + }; + + let data = match update { + CallUpdate::StreamItem { data } => { + data + }, + CallUpdate::Complete { .. } => { + // This indicates the end of the stream. The payload + // should be empty. + self.handle.receiver.close(); + self.handle.queue_tx.disconnect(); + return Poll::Ready(None); + }, + CallUpdate::Error { status } => { + self.handle.receiver.close(); + self.handle.queue_tx.disconnect(); + return Poll::Ready(Some(Err(Error::from(status)))); + }, + }; + + let result = match M::decode(&data[..]) { + Ok(message) => { + Ok(message) + }, + Err(e) => { + self.handle.error(Status::InvalidArgument, true); + Err(e.into()) + }, + }; + + Poll::Ready(Some(result)) + } + + fn size_hint(&self) -> (usize, Option) { + self.handle.receiver.size_hint() + } +} + +impl FusedStream for ServerStream<'_, M> +where + M: Message + Default, +{ + fn is_terminated(&self) -> bool { + self.handle.receiver.is_terminated() + } +} + + +#[derive(Debug, Clone)] +pub struct UnaryRpc { + marker1: std::marker::PhantomData, + marker2: std::marker::PhantomData, + path: Path, +} + +impl UnaryRpc +where + M1: Message, + M2: Message + Default, +{ + pub fn new(path: impl Into) -> Self { + Self { + marker1: std::marker::PhantomData, + marker2: std::marker::PhantomData, + path: path.into(), + } + } + + pub fn call(&self, handle: &mut ClientHandle, channel_id: u32, call_id: u32, message: M1) + -> Result, Error> + { + let req = Request { + channel_id, + service_id: self.path.service().hash(), + method_id: self.path.method().hash(), + call_id, + message, + }; + + handle.call_unary(req) + } + + pub fn open(&self, handle: &mut ClientHandle, channel_id: u32, call_id: u32) + -> Result, Error> + { + let req = Request { + channel_id, + service_id: self.path.service().hash(), + method_id: self.path.method().hash(), + call_id, + message: (), + }; + + handle.open_unary(req) + } +} + + +#[derive(Debug, Clone)] +pub struct ServerStreamRpc { + marker1: std::marker::PhantomData, + marker2: std::marker::PhantomData, + path: Path, +} + +impl ServerStreamRpc +where + M1: Message, + M2: Message + Default, +{ + pub fn new(path: impl Into) -> Self { + Self { + marker1: std::marker::PhantomData, + marker2: std::marker::PhantomData, + path: path.into(), + } + } + + pub fn call(&self, handle: &mut ClientHandle, channel_id: u32, call_id: u32, message: M1) + -> Result, Error> + { + let req = Request { + channel_id, + service_id: self.path.service().hash(), + method_id: self.path.method().hash(), + call_id, + message, + }; + + handle.call_server_stream(req) + } + + pub fn open(&self, handle: &mut ClientHandle, channel_id: u32, call_id: u32) + -> Result, Error> + { + let req = Request { + channel_id, + service_id: self.path.service().hash(), + method_id: self.path.method().hash(), + call_id, + message: (), + }; + + handle.open_server_stream(req) + } +} diff --git a/libmaestro/src/pwrpc/id.rs b/libmaestro/src/pwrpc/id.rs new file mode 100644 index 0000000..eed394f --- /dev/null +++ b/libmaestro/src/pwrpc/id.rs @@ -0,0 +1,194 @@ +pub type Hash = u32; + + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Id { + name: String, +} + +impl Id { + pub fn new(id: impl Into) -> Self { + Self { name: id.into() } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn hash(&self) -> Hash { + hash::hash_65599(&self.name) + } + + pub fn as_ref(&self) -> IdRef<'_> { + IdRef { name: &self.name } + } +} + +impl From for Id +where + S: Into +{ + fn from(name: S) -> Self { + Id::new(name) + } +} + +impl<'a> From> for Id { + fn from(id: IdRef<'a>) -> Self { + Id::new(id.name()) + } +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IdRef<'a> { + name: &'a str, +} + +impl<'a> IdRef<'a> { + pub fn new(name: &'a str) -> Self { + Self { name } + } + + pub fn name(&self) -> &'a str { + self.name + } + + pub fn hash(&self) -> Hash { + hash::hash_65599(self.name) + } +} + +impl<'a> From<&'a str> for IdRef<'a> { + fn from(name: &'a str) -> Self { + IdRef::new(name) + } +} + +impl<'a> From<&'a String> for IdRef<'a> { + fn from(name: &'a String) -> Self { + IdRef::new(name) + } +} + + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Path { + path: String, + split: usize, +} + +impl Path { + pub fn new(path: impl Into) -> Self { + let path = path.into(); + let split = path.rfind('/').unwrap_or(0); + + Path { path, split } + } + + pub fn service(&self) -> IdRef<'_> { + IdRef::new(&self.path[..self.split]) + } + + pub fn method(&self) -> IdRef<'_> { + if self.split < self.path.len() { + IdRef::new(&self.path[self.split+1..]) + } else { + IdRef::new(&self.path[0..0]) + } + } + + pub fn as_ref(&self) -> PathRef<'_> { + PathRef { path: &self.path, split: self.split } + } +} + +impl From<&str> for Path { + fn from(name: &str) -> Self { + Path::new(name) + } +} + +impl From for Path { + fn from(name: String) -> Self { + Path::new(name) + } +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PathRef<'a> { + path: &'a str, + split: usize, +} + +impl<'a> PathRef<'a> { + pub fn new(path: &'a str) -> Self { + let split = path.rfind('/').unwrap_or(0); + + PathRef { path, split } + } + + pub fn service(&self) -> IdRef<'a> { + IdRef::new(&self.path[..self.split]) + } + + pub fn method(&self) -> IdRef<'a> { + if self.split < self.path.len() { + IdRef::new(&self.path[self.split+1..]) + } else { + IdRef::new(&self.path[0..0]) + } + } +} + +impl<'a> From<&'a str> for PathRef<'a> { + fn from(name: &'a str) -> Self { + PathRef::new(name) + } +} + + +mod hash { + const HASH_CONST: u32 = 65599; + + pub fn hash_65599(id: &str) -> u32 { + let mut hash = id.len() as u32; + let mut coef = HASH_CONST; + + for chr in id.chars() { + hash = hash.wrapping_add(coef.wrapping_mul(chr as u32)); + coef = coef.wrapping_mul(HASH_CONST); + } + + hash + } +} + + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_known_id_hashes() { + assert_eq!(IdRef::new("maestro_pw.Maestro").hash(), 0x7ede71ea); + assert_eq!(IdRef::new("GetSoftwareInfo").hash(), 0x7199fa44); + assert_eq!(IdRef::new("SubscribeToSettingsChanges").hash(), 0x2821adf5); + } + + #[test] + fn test_path() { + let pref = PathRef::new("maestro_pw.Maestro/GetSoftwareInfo"); + assert_eq!(pref.service().name(), "maestro_pw.Maestro"); + assert_eq!(pref.service().hash(), 0x7ede71ea); + assert_eq!(pref.method().name(), "GetSoftwareInfo"); + assert_eq!(pref.method().hash(), 0x7199fa44); + + let pref = PathRef::new("maestro_pw.Maestro/SubscribeToSettingsChanges"); + assert_eq!(pref.service().name(), "maestro_pw.Maestro"); + assert_eq!(pref.service().hash(), 0x7ede71ea); + assert_eq!(pref.method().name(), "SubscribeToSettingsChanges"); + assert_eq!(pref.method().hash(), 0x2821adf5); + } +} diff --git a/libmaestro/src/pwrpc/mod.rs b/libmaestro/src/pwrpc/mod.rs new file mode 100644 index 0000000..8c0f0b0 --- /dev/null +++ b/libmaestro/src/pwrpc/mod.rs @@ -0,0 +1,8 @@ +pub mod client; +pub mod id; +pub mod types; +pub mod utils; + +mod status; +pub use status::Error; +pub use status::Status; diff --git a/libmaestro/src/pwrpc/status.rs b/libmaestro/src/pwrpc/status.rs new file mode 100644 index 0000000..6169451 --- /dev/null +++ b/libmaestro/src/pwrpc/status.rs @@ -0,0 +1,236 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Status { + Ok = 0, + Cancelled = 1, + Unknown = 2, + InvalidArgument = 3, + DeadlineExceeded = 4, + NotFound = 5, + AlreadyExists = 6, + PermissionDenied = 7, + ResourceExhausted = 8, + FailedPrecondition = 9, + Aborted = 10, + OutOfRange = 11, + Unimplemented = 12, + Internal = 13, + Unavailable = 14, + DataLoss = 15, + Unauthenticated = 16, +} + +impl Status { + pub fn description(&self) -> &'static str { + match self { + Status::Ok => "The operation completed successfully", + Status::Cancelled => "The operation was cancelled", + Status::Unknown => "Unknown error", + Status::InvalidArgument => "Client specified an invalid argument", + Status::DeadlineExceeded => "Deadline expired before operation could complete", + Status::NotFound => "Some requested entity was not found", + Status::AlreadyExists => "Some entity that we attempted to create already exists", + Status::PermissionDenied => "The caller does not have permission to execute the specified operation", + Status::ResourceExhausted => "Some resource has been exhausted", + Status::FailedPrecondition => "The system is not in a state required for the operation's execution", + Status::Aborted => "The operation was aborted", + Status::OutOfRange => "Operation was attempted past the valid range", + Status::Unimplemented => "Operation is not implemented or not supported", + Status::Internal => "Internal error", + Status::Unavailable => "The service is currently unavailable", + Status::DataLoss => "Unrecoverable data loss or corruption", + Status::Unauthenticated => "The request does not have valid authentication credentials", + } + } +} + +impl std::fmt::Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.description()) + } +} + +impl From for Status { + fn from(value: u32) -> Self { + match value { + 0 => Status::Ok, + 1 => Status::Cancelled, + 2 => Status::Unknown, + 3 => Status::InvalidArgument, + 4 => Status::DeadlineExceeded, + 5 => Status::NotFound, + 6 => Status::AlreadyExists, + 7 => Status::PermissionDenied, + 8 => Status::ResourceExhausted, + 9 => Status::FailedPrecondition, + 10 => Status::Aborted, + 11 => Status::OutOfRange, + 12 => Status::Unimplemented, + 13 => Status::Internal, + 14 => Status::Unavailable, + 15 => Status::DataLoss, + 16 => Status::Unauthenticated, + _ => Status::Unknown, + } + } +} + +impl From for u32 { + fn from(value: Status) -> Self { + value as _ + } +} + + +#[derive(Debug)] +pub struct Error { + code: Status, + message: String, + source: Option>, +} + +impl Error { + pub fn new(code: Status, message: impl Into) -> Self { + Self { + code, + message: message.into(), + source: None, + } + } + + pub fn cancelled(message: impl Into) -> Self { + Self::new(Status::Cancelled, message) + } + + pub fn unknown(message: impl Into) -> Self { + Self::new(Status::Unknown, message) + } + + pub fn invalid_argument(message: impl Into) -> Self { + Self::new(Status::InvalidArgument, message) + } + + pub fn deadline_exceeded(message: impl Into) -> Self { + Self::new(Status::DeadlineExceeded, message) + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(Status::NotFound, message) + } + + pub fn already_exists(message: impl Into) -> Self { + Self::new(Status::AlreadyExists, message) + } + + pub fn permission_denied(message: impl Into) -> Self { + Self::new(Status::PermissionDenied, message) + } + + pub fn resource_exhausted(message: impl Into) -> Self { + Self::new(Status::ResourceExhausted, message) + } + + pub fn failed_precondition(message: impl Into) -> Self { + Self::new(Status::FailedPrecondition, message) + } + + pub fn aborted(message: impl Into) -> Self { + Self::new(Status::Aborted, message) + } + + pub fn out_of_range(message: impl Into) -> Self { + Self::new(Status::OutOfRange, message) + } + + pub fn unimplemented(message: impl Into) -> Self { + Self::new(Status::Unimplemented, message) + } + + pub fn internal(message: impl Into) -> Self { + Self::new(Status::Internal, message) + } + + pub fn unavailable(message: impl Into) -> Self { + Self::new(Status::Unavailable, message) + } + + pub fn data_loss(message: impl Into) -> Self { + Self::new(Status::DataLoss, message) + } + + pub fn unauthenticated(message: impl Into) -> Self { + Self::new(Status::Unauthenticated, message) + } + + pub fn extend( + code: Status, + message: impl Into, + error: impl Into>, + ) -> Self { + Self { + code, + message: message.into(), + source: Some(error.into()), + } + } + + pub fn code(&self) -> Status { + self.code + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl From for Error { + fn from(code: Status) -> Self { + Self::new(code, code.description()) + } +} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + use std::io::ErrorKind; + + let code = match err.kind() { + ErrorKind::BrokenPipe + | ErrorKind::WouldBlock + | ErrorKind::WriteZero + | ErrorKind::Interrupted => Status::Internal, + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::NotConnected + | ErrorKind::AddrInUse + | ErrorKind::AddrNotAvailable => Status::Unavailable, + ErrorKind::AlreadyExists => Status::AlreadyExists, + ErrorKind::ConnectionAborted => Status::Aborted, + ErrorKind::InvalidData => Status::DataLoss, + ErrorKind::InvalidInput => Status::InvalidArgument, + ErrorKind::NotFound => Status::NotFound, + ErrorKind::PermissionDenied => Status::PermissionDenied, + ErrorKind::TimedOut => Status::DeadlineExceeded, + ErrorKind::UnexpectedEof => Status::OutOfRange, + _ => Status::Unknown, + }; + + Error::extend(code, err.to_string(), err) + } +} + +impl From for Error { + fn from(error: prost::DecodeError) -> Self { + Self::extend(Status::InvalidArgument, "failed to decode message", error) + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "status: {:?}, message: {:?}", self.code, self.message) + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source.as_ref().map(|err| (&**err) as _) + } +} diff --git a/libmaestro/src/pwrpc/types.rs b/libmaestro/src/pwrpc/types.rs new file mode 100644 index 0000000..283c1f1 --- /dev/null +++ b/libmaestro/src/pwrpc/types.rs @@ -0,0 +1,31 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RpcType { + Unary, + ServerStream, + ClientStream, + BidirectionalStream, +} + +impl RpcType { + pub fn has_server_stream(&self) -> bool { + match *self { + RpcType::ServerStream | RpcType::BidirectionalStream => true, + RpcType::Unary | RpcType::ClientStream => false, + } + } + + pub fn has_client_stream(&self) -> bool { + match *self { + RpcType::ClientStream | RpcType::BidirectionalStream => true, + RpcType::Unary | RpcType::ServerStream => false, + } + } +} + + +mod generated { + include!(concat!(env!("OUT_DIR"), "/pw.rpc.packet.rs")); +} + +pub use generated::PacketType; +pub use generated::RpcPacket; diff --git a/libmaestro/src/pwrpc/utils.rs b/libmaestro/src/pwrpc/utils.rs new file mode 100644 index 0000000..c0dd9c8 --- /dev/null +++ b/libmaestro/src/pwrpc/utils.rs @@ -0,0 +1,56 @@ +//! Miscellaneous utilities and helpers. + +use bytes::{Buf, BufMut}; + +/// An encoded protobuf message. +/// +/// This type represents an encoded protobuf message. Decoding and encoding are +/// essentially no-ops, reading and writing to/from the internal buffer. It is +/// a drop-in replacement for any valid (and invalid) protobuf type. +/// +/// This type is intended for reverse-engineering and testing, e.g., in +/// combination with tools like `protoscope`. +#[derive(Clone, Default)] +pub struct EncodedMessage { + pub data: Vec, +} + +impl std::fmt::Debug for EncodedMessage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:02x?}", self.data) + } +} + +impl prost::Message for EncodedMessage { + fn encode_raw(&self, buf: &mut impl BufMut) { + buf.put_slice(&self.data[..]) + } + + fn merge_field( + &mut self, + _tag: u32, + _wire_type: prost::encoding::WireType, + _buf: &mut impl Buf, + _ctx: prost::encoding::DecodeContext, + ) -> Result<(), prost::DecodeError> { + unimplemented!("use merge() instead") + } + + fn merge(&mut self, mut buf: impl Buf) -> Result<(), prost::DecodeError> { + let a = self.data.len(); + let b = a + buf.remaining(); + + self.data.resize(b, 0); + buf.copy_to_slice(&mut self.data[a..b]); + + Ok(()) + } + + fn encoded_len(&self) -> usize { + self.data.len() + } + + fn clear(&mut self) { + self.data.clear() + } +} diff --git a/libmaestro/src/service/impls/dosimeter.rs b/libmaestro/src/service/impls/dosimeter.rs new file mode 100644 index 0000000..42299dd --- /dev/null +++ b/libmaestro/src/service/impls/dosimeter.rs @@ -0,0 +1,36 @@ +use crate::protocol::types::{ + DosimeterSummary, DosimeterLiveDbMsg, +}; +use crate::pwrpc::client::{ClientHandle, ServerStreamRpc, StreamResponse, UnaryRpc}; +use crate::pwrpc::Error; + + +#[derive(Debug, Clone)] +pub struct DosimeterService { + client: ClientHandle, + channel_id: u32, + + rpc_fetch_daily_summaries: UnaryRpc<(), DosimeterSummary>, + rpc_sub_live_db: ServerStreamRpc<(), DosimeterLiveDbMsg>, +} + +impl DosimeterService { + pub fn new(client: ClientHandle, channel_id: u32) -> Self { + Self { + client, + channel_id, + + rpc_fetch_daily_summaries: UnaryRpc::new("maestro_pw.Dosimeter/FetchDailySummaries"), + rpc_sub_live_db: ServerStreamRpc::new("maestro_pw.Dosimeter/SubscribeToLiveDb"), + } + } + + pub async fn fetch_daily_summaries(&mut self) -> Result { + self.rpc_fetch_daily_summaries.call(&mut self.client, self.channel_id, 0, ())? + .result().await + } + + pub fn subscribe_to_live_db(&mut self) -> Result, Error> { + self.rpc_sub_live_db.call(&mut self.client, self.channel_id, 0, ()) + } +} diff --git a/libmaestro/src/service/impls/maestro.rs b/libmaestro/src/service/impls/maestro.rs new file mode 100644 index 0000000..c89abf9 --- /dev/null +++ b/libmaestro/src/service/impls/maestro.rs @@ -0,0 +1,117 @@ +use crate::protocol::types::{ + self, read_setting_msg, settings_rsp, write_setting_msg, HardwareInfo, OobeActionRsp, + ReadSettingMsg, RuntimeInfo, SettingsRsp, SoftwareInfo, WriteSettingMsg, +}; +use crate::pwrpc::client::{ClientHandle, ServerStreamRpc, StreamResponse, UnaryRpc}; +use crate::pwrpc::Error; +use crate::service::settings::{Setting, SettingId, SettingValue}; + + +#[derive(Debug, Clone)] +pub struct MaestroService { + client: ClientHandle, + channel_id: u32, + + rpc_get_software_info: UnaryRpc<(), SoftwareInfo>, + rpc_get_hardware_info: UnaryRpc<(), HardwareInfo>, + rpc_sub_runtime_info: ServerStreamRpc<(), RuntimeInfo>, + + rpc_write_setting: UnaryRpc, + rpc_read_setting: UnaryRpc, + rpc_sub_settings_changes: ServerStreamRpc<(), SettingsRsp>, + + rpc_sub_oobe_actions: ServerStreamRpc<(), OobeActionRsp>, +} + +impl MaestroService { + pub fn new(client: ClientHandle, channel_id: u32) -> Self { + Self { + client, + channel_id, + + rpc_get_software_info: UnaryRpc::new("maestro_pw.Maestro/GetSoftwareInfo"), + rpc_get_hardware_info: UnaryRpc::new("maestro_pw.Maestro/GetHardwareInfo"), + rpc_sub_runtime_info: ServerStreamRpc::new("maestro_pw.Maestro/SubscribeRuntimeInfo"), + + rpc_write_setting: UnaryRpc::new("maestro_pw.Maestro/WriteSetting"), + rpc_read_setting: UnaryRpc::new("maestro_pw.Maestro/ReadSetting"), + rpc_sub_settings_changes: ServerStreamRpc::new("maestro_pw.Maestro/SubscribeToSettingsChanges"), + + rpc_sub_oobe_actions: ServerStreamRpc::new("maestro_pw.Maestro/SubscribeToOobeActions"), + } + } + + pub async fn get_software_info(&mut self) -> Result { + self.rpc_get_software_info.call(&mut self.client, self.channel_id, 0, ())? + .result().await + } + + pub async fn get_hardware_info(&mut self) -> Result { + self.rpc_get_hardware_info.call(&mut self.client, self.channel_id, 0, ())? + .result().await + } + + pub fn subscribe_to_runtime_info(&mut self) -> Result, Error> { + self.rpc_sub_runtime_info.call(&mut self.client, self.channel_id, 0, ()) + } + + pub async fn write_setting_raw(&mut self, setting: WriteSettingMsg) -> Result<(), Error> { + self.rpc_write_setting.call(&mut self.client, self.channel_id, 0, setting)? + .result().await + } + + pub async fn write_setting(&mut self, setting: SettingValue) -> Result<(), Error> { + let setting = types::SettingValue { + value_oneof: Some(setting.into()), + }; + + let setting = WriteSettingMsg { + value_oneof: Some(write_setting_msg::ValueOneof::Setting(setting)), + }; + + self.write_setting_raw(setting).await + } + + pub async fn read_setting_raw(&mut self, setting: ReadSettingMsg) -> Result { + self.rpc_read_setting.call(&mut self.client, self.channel_id, 0, setting)? + .result().await + } + + pub async fn read_setting_var(&mut self, setting: SettingId) -> Result { + let setting = read_setting_msg::ValueOneof::SettingsId(setting.into()); + let setting = ReadSettingMsg { value_oneof: Some(setting) }; + + let value = self.read_setting_raw(setting).await?; + + let value = value.value_oneof + .ok_or_else(|| Error::invalid_argument("did not receive any settings value"))?; + + let settings_rsp::ValueOneof::Value(value) = value; + + let value = value.value_oneof + .ok_or_else(|| Error::invalid_argument("did not receive any settings value"))?; + + Ok(value.into()) + } + + pub async fn read_setting(&mut self, setting: T) -> Result + where + T: Setting, + { + let value = self.read_setting_var(setting.id()).await?; + + T::from_var(value) + .ok_or_else(|| Error::invalid_argument("failed to decode settings value")) + } + + pub fn subscribe_to_settings_changes(&mut self) -> Result, Error> { + self.rpc_sub_settings_changes.call(&mut self.client, self.channel_id, 0, ()) + } + + pub fn subscribe_to_oobe_actions(&mut self) -> Result, Error> { + self.rpc_sub_oobe_actions.call(&mut self.client, self.channel_id, 0, ()) + } + + // TODO: + // - SetWallClock +} diff --git a/libmaestro/src/service/impls/mod.rs b/libmaestro/src/service/impls/mod.rs new file mode 100644 index 0000000..82ec671 --- /dev/null +++ b/libmaestro/src/service/impls/mod.rs @@ -0,0 +1,8 @@ +mod dosimeter; +pub use self::dosimeter::DosimeterService; + +mod maestro; +pub use self::maestro::MaestroService; + +mod multipoint; +pub use self::multipoint::MultipointService; diff --git a/libmaestro/src/service/impls/multipoint.rs b/libmaestro/src/service/impls/multipoint.rs new file mode 100644 index 0000000..7e7fa5e --- /dev/null +++ b/libmaestro/src/service/impls/multipoint.rs @@ -0,0 +1,30 @@ +use crate::protocol::types::QuietModeStatusEvent; +use crate::pwrpc::client::{ClientHandle, ServerStreamRpc, StreamResponse}; +use crate::pwrpc::Error; + + +#[derive(Debug, Clone)] +pub struct MultipointService { + client: ClientHandle, + channel_id: u32, + + rpc_sub_quiet_mode_status: ServerStreamRpc<(), QuietModeStatusEvent>, +} + +impl MultipointService { + pub fn new(client: ClientHandle, channel_id: u32) -> Self { + Self { + client, + channel_id, + + rpc_sub_quiet_mode_status: ServerStreamRpc::new("maestro_pw.Multipoint/SubscribeToQuietModeStatus"), + } + } + + pub fn subscribe_to_quiet_mode_status(&mut self) -> Result, Error> { + self.rpc_sub_quiet_mode_status.call(&mut self.client, self.channel_id, 0, ()) + } + + // TODO: + // - ForceMultipointSwitch +} diff --git a/libmaestro/src/service/mod.rs b/libmaestro/src/service/mod.rs new file mode 100644 index 0000000..3432242 --- /dev/null +++ b/libmaestro/src/service/mod.rs @@ -0,0 +1,4 @@ +pub mod settings; + +mod impls; +pub use impls::{MaestroService, MultipointService, DosimeterService}; diff --git a/libmaestro/src/service/settings.rs b/libmaestro/src/service/settings.rs new file mode 100644 index 0000000..392d8e8 --- /dev/null +++ b/libmaestro/src/service/settings.rs @@ -0,0 +1,829 @@ +use num_enum::{IntoPrimitive, FromPrimitive}; + +use crate::protocol::types; + + +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum SettingId { + AutoOtaEnable = 1, + OhdEnable = 2, + OobeIsFinished = 3, + GestureEnable = 4, + DiagnosticsEnable = 5, + OobeMode = 6, + GestureControl = 7, + AncAccessibilityMode = 8, + AncrStateOneBud = 9, + AncrStateTwoBuds = 10, + MultipointEnable = 11, + AncrGestureLoop = 12, + CurrentAncrState = 13, + OttsMode = 14, + VolumeEqEnable = 15, + CurrentUserEq = 16, + VolumeAsymmetry = 17, + LastSavedUserEq = 18, + SumToMono = 19, + VolumeExposureNotifications = 21, + SpeechDetection = 22, + + #[num_enum(catch_all)] + Unknown(i32), +} + + +#[derive(Debug, Clone, PartialEq)] +pub enum SettingValue { + AutoOtaEnable(bool), + OhdEnable(bool), + OobeIsFinished(bool), + GestureEnable(bool), + DiagnosticsEnable(bool), + OobeMode(bool), + GestureControl(GestureControl), + MultipointEnable(bool), + AncrGestureLoop(AncrGestureLoop), + CurrentAncrState(AncState), + OttsMode(i32), + VolumeEqEnable(bool), + CurrentUserEq(EqBands), + VolumeAsymmetry(VolumeAsymmetry), + SumToMono(bool), + VolumeExposureNotifications(bool), + SpeechDetection(bool), +} + +impl SettingValue { + pub fn id(&self) -> SettingId { + match self { + SettingValue::AutoOtaEnable(_) => SettingId::AutoOtaEnable, + SettingValue::OhdEnable(_) => SettingId::OhdEnable, + SettingValue::OobeIsFinished(_) => SettingId::OobeIsFinished, + SettingValue::GestureEnable(_) => SettingId::GestureEnable, + SettingValue::DiagnosticsEnable(_) => SettingId::DiagnosticsEnable, + SettingValue::OobeMode(_) => SettingId::OobeMode, + SettingValue::GestureControl(_) => SettingId::GestureControl, + SettingValue::MultipointEnable(_) => SettingId::MultipointEnable, + SettingValue::AncrGestureLoop(_) => SettingId::AncrGestureLoop, + SettingValue::CurrentAncrState(_) => SettingId::CurrentAncrState, + SettingValue::OttsMode(_) => SettingId::OttsMode, + SettingValue::VolumeEqEnable(_) => SettingId::VolumeEqEnable, + SettingValue::CurrentUserEq(_) => SettingId::CurrentUserEq, + SettingValue::VolumeAsymmetry(_) => SettingId::VolumeAsymmetry, + SettingValue::SumToMono(_) => SettingId::SumToMono, + SettingValue::VolumeExposureNotifications(_) => SettingId::VolumeExposureNotifications, + SettingValue::SpeechDetection(_) => SettingId::SpeechDetection, + } + } +} + +impl From for SettingValue { + fn from(value: crate::protocol::types::setting_value::ValueOneof) -> Self { + use types::setting_value::ValueOneof; + + match value { + ValueOneof::AutoOtaEnable(x) => SettingValue::AutoOtaEnable(x), + ValueOneof::OhdEnable(x) => SettingValue::OhdEnable(x), + ValueOneof::OobeIsFinished(x) => SettingValue::OobeIsFinished(x), + ValueOneof::GestureEnable(x) => SettingValue::GestureEnable(x), + ValueOneof::DiagnosticsEnable(x) => SettingValue::DiagnosticsEnable(x), + ValueOneof::OobeMode(x) => SettingValue::OobeMode(x), + ValueOneof::GestureControl(x) => SettingValue::GestureControl(GestureControl::from(x)), + ValueOneof::MultipointEnable(x) => SettingValue::MultipointEnable(x), + ValueOneof::AncrGestureLoop(x) => SettingValue::AncrGestureLoop(AncrGestureLoop::from(x)), + ValueOneof::CurrentAncrState(x) => SettingValue::CurrentAncrState(AncState::from_primitive(x)), + ValueOneof::OttsMode(x) => SettingValue::OttsMode(x), + ValueOneof::VolumeEqEnable(x) => SettingValue::VolumeEqEnable(x), + ValueOneof::CurrentUserEq(x) => SettingValue::CurrentUserEq(EqBands::from(x)), + ValueOneof::VolumeAsymmetry(x) => SettingValue::VolumeAsymmetry(VolumeAsymmetry::from_raw(x)), + ValueOneof::SumToMono(x) => SettingValue::SumToMono(x), + ValueOneof::VolumeExposureNotifications(x) => SettingValue::VolumeExposureNotifications(x), + ValueOneof::SpeechDetection(x) => SettingValue::SpeechDetection(x), + } + } +} + +impl From for types::setting_value::ValueOneof { + fn from(value: SettingValue) -> Self { + use types::setting_value::ValueOneof; + + match value { + SettingValue::AutoOtaEnable(x) => ValueOneof::AutoOtaEnable(x), + SettingValue::OhdEnable(x) => ValueOneof::OhdEnable(x), + SettingValue::OobeIsFinished(x) => ValueOneof::OobeIsFinished(x), + SettingValue::GestureEnable(x) => ValueOneof::GestureEnable(x), + SettingValue::DiagnosticsEnable(x) => ValueOneof::DiagnosticsEnable(x), + SettingValue::OobeMode(x) => ValueOneof::OobeMode(x), + SettingValue::GestureControl(x) => ValueOneof::GestureControl(x.into()), + SettingValue::MultipointEnable(x) => ValueOneof::MultipointEnable(x), + SettingValue::AncrGestureLoop(x) => ValueOneof::AncrGestureLoop(x.into()), + SettingValue::CurrentAncrState(x) => ValueOneof::CurrentAncrState(x.into()), + SettingValue::OttsMode(x) => ValueOneof::OttsMode(x), + SettingValue::VolumeEqEnable(x) => ValueOneof::VolumeEqEnable(x), + SettingValue::CurrentUserEq(x) => ValueOneof::CurrentUserEq(x.into()), + SettingValue::VolumeAsymmetry(x) => ValueOneof::VolumeAsymmetry(x.raw()), + SettingValue::SumToMono(x) => ValueOneof::SumToMono(x), + SettingValue::VolumeExposureNotifications(x) => ValueOneof::VolumeExposureNotifications(x), + SettingValue::SpeechDetection(x) => ValueOneof::SpeechDetection(x), + } + } +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GestureControl { + pub left: RegularActionTarget, + pub right: RegularActionTarget, +} + +impl From for GestureControl { + fn from(value: types::GestureControl) -> Self { + let left = value.left + .and_then(|v| v.value_oneof) + .map(|types::device_gesture_control::ValueOneof::Type(x)| x) + .map(|v| RegularActionTarget::from_primitive(v.value)) + .unwrap_or(RegularActionTarget::Unknown(-1)); + + let right = value.right + .and_then(|v| v.value_oneof) + .map(|types::device_gesture_control::ValueOneof::Type(x)| x) + .map(|v| RegularActionTarget::from_primitive(v.value)) + .unwrap_or(RegularActionTarget::Unknown(-1)); + + GestureControl { left, right } + } +} + +impl From for types::GestureControl { + fn from(value: GestureControl) -> Self { + use types::device_gesture_control::ValueOneof; + + let left = types::DeviceGestureControl { + value_oneof: Some(ValueOneof::Type(types::GestureControlType { + value: value.left.into(), + })), + }; + + let right = types::DeviceGestureControl { + value_oneof: Some(ValueOneof::Type(types::GestureControlType { + value: value.right.into(), + })), + }; + + Self { + left: Some(left), + right: Some(right), + } + } +} + +impl Default for GestureControl { + fn default() -> Self { + Self { + left: RegularActionTarget::AncControl, + right: RegularActionTarget::AncControl, + } + } +} + +impl std::fmt::Display for GestureControl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "left: {}, right: {}", self.left, self.right) + } +} + + +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum RegularActionTarget { + CheckNotifications = 1, + PreviousTrackRepeat = 2, + NextTrack = 3, + PlayPauseTrack = 4, + AncControl = 5, + AssistantQuery = 6, + + #[num_enum(catch_all)] + Unknown(i32), +} + +impl RegularActionTarget { + pub fn as_str(&self) -> &'static str { + match self { + RegularActionTarget::CheckNotifications => "check-notifications", + RegularActionTarget::PreviousTrackRepeat => "previous", + RegularActionTarget::NextTrack => "next", + RegularActionTarget::PlayPauseTrack => "play-pause", + RegularActionTarget::AncControl => "anc", + RegularActionTarget::AssistantQuery => "assistant", + RegularActionTarget::Unknown(_) => "unknown", + } + } +} + +impl std::fmt::Display for RegularActionTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RegularActionTarget::CheckNotifications => write!(f, "check-notifications"), + RegularActionTarget::PreviousTrackRepeat => write!(f, "previous"), + RegularActionTarget::NextTrack => write!(f, "next"), + RegularActionTarget::PlayPauseTrack => write!(f, "play-pause"), + RegularActionTarget::AncControl => write!(f, "anc"), + RegularActionTarget::AssistantQuery => write!(f, "assistant"), + RegularActionTarget::Unknown(x) => write!(f, "unknown ({x})"), + } + } +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AncrGestureLoop { + pub active: bool, + pub off: bool, + pub aware: bool, + pub adaptive: bool, +} + +impl AncrGestureLoop { + pub fn is_valid(&self) -> bool { + // at least two need to be set + (self.active as u32 + self.off as u32 + self.aware as u32 + self.adaptive as u32) >= 2 + } +} + +impl From for AncrGestureLoop { + fn from(other: types::AncrGestureLoop) -> Self { + AncrGestureLoop { active: other.active, off: other.off, aware: other.aware, adaptive: other.adaptive } + } +} + +impl From for types::AncrGestureLoop { + fn from(other: AncrGestureLoop) -> Self { + Self { + active: other.active, + off: other.off, + aware: other.aware, + adaptive: other.adaptive, + } + } +} + +impl std::fmt::Display for AncrGestureLoop { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut n = 0; + + write!(f, "[")?; + + if self.active { + write!(f, "active")?; + n += 1; + } + + if self.off { + if n > 0 { + write!(f, ", ")?; + } + + write!(f, "off")?; + n += 1; + } + + if self.aware { + if n > 0 { + write!(f, ", ")?; + } + + write!(f, "aware")?; + } + + + if self.adaptive { + if n > 0 { + write!(f, ", ")?; + } + + write!(f, "adaptive")?; + } + + write!(f, "]") + } +} + + +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)] +pub enum AncState { + Off = 1, + Active = 2, + Aware = 3, + Adaptive = 4, + + #[num_enum(catch_all)] + Unknown(i32), +} + +impl AncState { + pub fn as_str(&self) -> &'static str { + match self { + AncState::Off => "off", + AncState::Active => "active", + AncState::Aware => "aware", + AncState::Adaptive => "adaptive", + AncState::Unknown(_) => "unknown", + } + } +} + +// #[derive(Default)] clashes with #[derive(FromPrimitive)] +#[allow(clippy::derivable_impls)] +impl Default for AncState { + fn default() -> Self { + AncState::Off + } +} + +impl std::fmt::Display for AncState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AncState::Off => write!(f, "off"), + AncState::Active => write!(f, "active"), + AncState::Aware => write!(f, "aware"), + AncState::Adaptive => write!(f, "adaptive"), + AncState::Unknown(x) => write!(f, "unknown ({x})"), + } + } +} + + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EqBands { + low_bass: f32, + bass: f32, + mid: f32, + treble: f32, + upper_treble: f32, +} + +impl EqBands { + pub const MIN_VALUE: f32 = -6.0; + pub const MAX_VALUE: f32 = 6.0; + + pub fn new(low_bass: f32, bass: f32, mid: f32, treble: f32, upper_treble: f32) -> Self { + Self { + low_bass: low_bass.clamp(Self::MIN_VALUE, Self::MAX_VALUE), + bass: bass.clamp(Self::MIN_VALUE, Self::MAX_VALUE), + mid: mid.clamp(Self::MIN_VALUE, Self::MAX_VALUE), + treble: treble.clamp(Self::MIN_VALUE, Self::MAX_VALUE), + upper_treble: upper_treble.clamp(Self::MIN_VALUE, Self::MAX_VALUE), + } + } + + pub fn low_bass(&self) -> f32 { + self.low_bass + } + + pub fn bass(&self) -> f32 { + self.bass + } + + pub fn mid(&self) -> f32 { + self.mid + } + + pub fn treble(&self) -> f32 { + self.treble + } + + pub fn upper_treble(&self) -> f32 { + self.upper_treble + } + + pub fn set_low_bass(&mut self, value: f32) { + self.low_bass = value.clamp(Self::MIN_VALUE, Self::MAX_VALUE) + } + + pub fn set_bass(&mut self, value: f32) { + self.bass = value.clamp(Self::MIN_VALUE, Self::MAX_VALUE) + } + + pub fn set_mid(&mut self, value: f32) { + self.mid = value.clamp(Self::MIN_VALUE, Self::MAX_VALUE) + } + + pub fn set_treble(&mut self, value: f32) { + self.treble = value.clamp(Self::MIN_VALUE, Self::MAX_VALUE) + } + + pub fn set_upper_treble(&mut self, value: f32) { + self.upper_treble = value.clamp(Self::MIN_VALUE, Self::MAX_VALUE) + } +} + +impl Default for EqBands { + fn default() -> Self { + Self { + low_bass: 0.0, + bass: 0.0, + mid: 0.0, + treble: 0.0, + upper_treble: 0.0 + } + } +} + +impl From for EqBands { + fn from(other: types::EqBands) -> Self { + Self { + low_bass: other.low_bass, + bass: other.bass, + mid: other.mid, + treble: other.treble, + upper_treble: other.upper_treble, + } + } +} + +impl From for types::EqBands { + fn from(other: EqBands) -> Self { + Self { + low_bass: other.low_bass.clamp(EqBands::MIN_VALUE, EqBands::MAX_VALUE), + bass: other.bass.clamp(EqBands::MIN_VALUE, EqBands::MAX_VALUE), + mid: other.mid.clamp(EqBands::MIN_VALUE, EqBands::MAX_VALUE), + treble: other.treble.clamp(EqBands::MIN_VALUE, EqBands::MAX_VALUE), + upper_treble: other.upper_treble.clamp(EqBands::MIN_VALUE, EqBands::MAX_VALUE), + } + } +} + +impl std::fmt::Display for EqBands { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, "[{:.2}, {:.2}, {:.2}, {:.2}, {:.2}]", + self.low_bass, self.bass, self.mid, self.treble, self.upper_treble, + ) + } +} + + +#[derive(Default, Clone, Copy, PartialEq, Eq)] +pub struct VolumeAsymmetry { + value: i32, +} + +impl VolumeAsymmetry { + pub fn from_normalized(value: i32) -> Self { + Self { value: value.clamp(-100, 100) } + } + + pub fn from_raw(value: i32) -> Self { + let direction = value & 0x01; + let value = value >> 1; + + let normalized = if direction != 0 { + value + 1 + } else { + - value + }; + + Self { value: normalized } + } + + pub fn raw(&self) -> i32 { + if self.value > 0 { + ((self.value - 1) << 1) | 0x01 + } else { + (-self.value) << 1 + } + } + + pub fn value(&self) -> i32 { + self.value + } +} + +impl std::fmt::Debug for VolumeAsymmetry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.value) + } +} + +impl std::fmt::Display for VolumeAsymmetry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let left = (100 - self.value).min(100); + let right = (100 + self.value).min(100); + + write!(f, "left: {left}%, right: {right}%") + } +} + + +pub trait Setting { + type Type; + + fn id(&self) -> SettingId; + fn from_var(var: SettingValue) -> Option; +} + +impl Setting for SettingId { + type Type = SettingValue; + + fn id(&self) -> SettingId { + *self + } + + fn from_var(var: SettingValue) -> Option { + Some(var) + } +} + + +pub mod id { + use super::*; + + pub struct AutoOtaEnable; + pub struct OhdEnable; + pub struct OobeIsFinished; + pub struct GestureEnable; + pub struct DiagnosticsEnable; + pub struct OobeMode; + pub struct GestureControl; + pub struct MultipointEnable; + pub struct AncrGestureLoop; + pub struct CurrentAncrState; + pub struct OttsMode; + pub struct VolumeEqEnable; + pub struct CurrentUserEq; + pub struct VolumeAsymmetry; + pub struct SumToMono; + pub struct VolumeExposureNotifications; + pub struct SpeechDetection; + + impl Setting for AutoOtaEnable { + type Type = bool; + + fn id(&self) -> SettingId { + SettingId::AutoOtaEnable + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::AutoOtaEnable(x) => Some(x), + _ => None, + } + } + } + + impl Setting for OhdEnable { + type Type = bool; + + fn id(&self) -> SettingId { + SettingId::OhdEnable + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::OhdEnable(x) => Some(x), + _ => None, + } + } + } + + impl Setting for OobeIsFinished { + type Type = bool; + + fn id(&self) -> SettingId { + SettingId::OobeIsFinished + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::OobeIsFinished(x) => Some(x), + _ => None, + } + } + } + + impl Setting for GestureEnable { + type Type = bool; + + fn id(&self) -> SettingId { + SettingId::GestureEnable + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::GestureEnable(x) => Some(x), + _ => None, + } + } + } + + impl Setting for DiagnosticsEnable { + type Type = bool; + + fn id(&self) -> SettingId { + SettingId::DiagnosticsEnable + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::DiagnosticsEnable(x) => Some(x), + _ => None, + } + } + } + + impl Setting for OobeMode { + type Type = bool; + + fn id(&self) -> SettingId { + SettingId::OobeMode + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::OobeMode(x) => Some(x), + _ => None, + } + } + } + + impl Setting for GestureControl { + type Type = super::GestureControl; + + fn id(&self) -> SettingId { + SettingId::GestureControl + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::GestureControl(x) => Some(x), + _ => None, + } + } + } + + impl Setting for MultipointEnable { + type Type = bool; + + fn id(&self) -> SettingId { + SettingId::MultipointEnable + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::MultipointEnable(x) => Some(x), + _ => None, + } + } + } + + impl Setting for AncrGestureLoop { + type Type = super::AncrGestureLoop; + + fn id(&self) -> SettingId { + SettingId::AncrGestureLoop + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::AncrGestureLoop(x) => Some(x), + _ => None, + } + } + } + + impl Setting for CurrentAncrState { + type Type = AncState; + + fn id(&self) -> SettingId { + SettingId::CurrentAncrState + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::CurrentAncrState(x) => Some(x), + _ => None, + } + } + } + + impl Setting for OttsMode { + type Type = i32; + + fn id(&self) -> SettingId { + SettingId::OttsMode + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::OttsMode(x) => Some(x), + _ => None, + } + } + } + + impl Setting for VolumeEqEnable { + type Type = bool; + + fn id(&self) -> SettingId { + SettingId::VolumeEqEnable + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::VolumeEqEnable(x) => Some(x), + _ => None, + } + } + } + + impl Setting for CurrentUserEq { + type Type = EqBands; + + fn id(&self) -> SettingId { + SettingId::CurrentUserEq + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::CurrentUserEq(x) => Some(x), + _ => None, + } + } + } + + impl Setting for VolumeAsymmetry { + type Type = super::VolumeAsymmetry; + + fn id(&self) -> SettingId { + SettingId::VolumeAsymmetry + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::VolumeAsymmetry(x) => Some(x), + _ => None, + } + } + } + + impl Setting for SumToMono { + type Type = bool; + + fn id(&self) -> SettingId { + SettingId::SumToMono + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::SumToMono(x) => Some(x), + _ => None, + } + } + } + + impl Setting for VolumeExposureNotifications { + type Type = bool; + + fn id(&self) -> SettingId { + SettingId::VolumeExposureNotifications + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::VolumeExposureNotifications(x) => Some(x), + _ => None, + } + } + } + + impl Setting for SpeechDetection { + type Type = bool; + + fn id(&self) -> SettingId { + SettingId::SpeechDetection + } + + fn from_var(var: SettingValue) -> Option { + match var { + SettingValue::SpeechDetection(x) => Some(x), + _ => None, + } + } + } +} + + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_volume_assymetry_conversion() { + for i in 0..=200 { + assert_eq!(VolumeAsymmetry::from_raw(i).raw(), i) + } + } +} diff --git a/tui/Cargo.toml b/tui/Cargo.toml new file mode 100644 index 0000000..3d75e98 --- /dev/null +++ b/tui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "pbpctui" +authors = ["Cikki "] +version = "0.1.5" +edition = "2024" +license = "MIT/Apache-2.0" +description = "TUI utility for pbpctrl" + +[dependencies] +ratatui = "0.30.0" +crossterm = { version = "0.29.0", features = ["event-stream"] } +tokio = { version = "1.44.2", features = ["full"] } +anyhow = "1.0.98" +regex = "1.10.4" +maestro = { path = "../libmaestro" } +bluer = { version = "0.17.4", features = ["bluetoothd", "rfcomm"] } +futures = "0.3.31" +tokio-util = { version = "0.7.14", features = ["codec"] } +tracing = "0.1.41" +tracing-subscriber = "0.3.20" diff --git a/tui/src/app.rs b/tui/src/app.rs new file mode 100644 index 0000000..4b491fd --- /dev/null +++ b/tui/src/app.rs @@ -0,0 +1,237 @@ +use ratatui::widgets::ListState; +use crate::maestro_client::{ConnectionState, BatteryState, SoftwareInfo, HardwareInfo, RuntimeInfo}; + +#[derive(Debug, Clone)] +pub struct SettingItem { + pub key: String, // Command key for CLI, e.g., "anc" + pub name: String, // Display name + pub value: String, // Current value string + pub options: Vec, // Possible values to cycle through. Empty means read-only. + pub index: Option, // For array-like settings (e.g., EQ bands) + pub range: Option<(f32, f32, f32)>, // (min, max, step) for numeric adjustments +} + +pub struct App { + pub should_quit: bool, + pub connection_state: ConnectionState, + pub battery: BatteryState, + pub software: SoftwareInfo, + pub hardware: HardwareInfo, + pub runtime: RuntimeInfo, + pub gesture_control: String, // Hold gestures info + pub selected_tab: usize, + pub tabs: Vec, + + pub settings_state: ListState, + pub settings: Vec, + + pub last_error: Option, + pub last_error_time: Option, +} + +impl App { + pub fn new() -> Self { + let mut settings_state = ListState::default(); + settings_state.select(Some(0)); + + Self { + should_quit: false, + connection_state: ConnectionState::Disconnected, + battery: BatteryState::default(), + software: SoftwareInfo::default(), + hardware: HardwareInfo::default(), + runtime: RuntimeInfo::default(), + gesture_control: "Unknown".to_string(), + selected_tab: 0, + tabs: vec!["Status".to_string(), "Settings".to_string()], + settings_state, + settings: vec![ + // --- Audio & Noise Control --- + SettingItem { + key: "anc".to_string(), + name: "ANC Mode".to_string(), + value: "Pending...".to_string(), + options: vec!["off".to_string(), "active".to_string(), "adaptive".to_string(), "aware".to_string()], + index: None, range: None, + }, + SettingItem { + key: "volume-eq".to_string(), + name: "Volume EQ".to_string(), + value: "Pending...".to_string(), + options: vec!["true".to_string(), "false".to_string()], + index: None, range: None, + }, + SettingItem { + key: "mono".to_string(), + name: "Mono Audio".to_string(), + value: "Pending...".to_string(), + options: vec!["true".to_string(), "false".to_string()], + index: None, range: None, + }, + SettingItem { + key: "speech-detection".to_string(), + name: "Conversation Detect".to_string(), + value: "Pending...".to_string(), + options: vec!["true".to_string(), "false".to_string()], + index: None, range: None, + }, + + // --- Connectivity & Sensors --- + SettingItem { + key: "multipoint".to_string(), + name: "Multipoint".to_string(), + value: "Pending...".to_string(), + options: vec!["true".to_string(), "false".to_string()], + index: None, range: None, + }, + SettingItem { + key: "ohd".to_string(), + name: "In-Ear Detection".to_string(), + value: "Pending...".to_string(), + options: vec!["true".to_string(), "false".to_string()], + index: None, range: None, + }, + + // --- Controls --- + SettingItem { + key: "gestures".to_string(), + name: "Touch Controls".to_string(), + value: "Pending...".to_string(), + options: vec!["true".to_string(), "false".to_string()], + index: None, range: None, + }, + + // --- System & Diagnostics --- + SettingItem { + key: "volume-exposure-notifications".to_string(), + name: "Volume Notifications".to_string(), + value: "Pending...".to_string(), + options: vec!["true".to_string(), "false".to_string()], + index: None, range: None, + }, + SettingItem { + key: "diagnostics".to_string(), + name: "Diagnostics".to_string(), + value: "Pending...".to_string(), + options: vec!["true".to_string(), "false".to_string()], + index: None, range: None, + }, + SettingItem { + key: "oobe-mode".to_string(), + name: "OOBE Mode".to_string(), + value: "Pending...".to_string(), + options: vec!["true".to_string(), "false".to_string()], + index: None, range: None, + }, + SettingItem { + key: "oobe-is-finished".to_string(), + name: "OOBE Finished".to_string(), + value: "Pending...".to_string(), + options: vec!["true".to_string(), "false".to_string()], + index: None, range: None, + }, + + // --- Numeric / Range Settings --- + SettingItem { + key: "balance".to_string(), + name: "Volume Balance".to_string(), + value: "Pending...".to_string(), + options: vec![], + index: None, + range: Some((-100.0, 100.0, 5.0)), // Min, Max, Step + }, + SettingItem { + key: "eq".to_string(), + name: "EQ: Low Bass".to_string(), + value: "Pending...".to_string(), + options: vec![], + index: Some(0), + range: Some((-6.0, 6.0, 0.5)), + }, + SettingItem { + key: "eq".to_string(), + name: "EQ: Bass".to_string(), + value: "Pending...".to_string(), + options: vec![], + index: Some(1), + range: Some((-6.0, 6.0, 0.5)), + }, + SettingItem { + key: "eq".to_string(), + name: "EQ: Mid".to_string(), + value: "Pending...".to_string(), + options: vec![], + index: Some(2), + range: Some((-6.0, 6.0, 0.5)), + }, + SettingItem { + key: "eq".to_string(), + name: "EQ: Treble".to_string(), + value: "Pending...".to_string(), + options: vec![], + index: Some(3), + range: Some((-6.0, 6.0, 0.5)), + }, + SettingItem { + key: "eq".to_string(), + name: "EQ: Upper Treble".to_string(), + value: "Pending...".to_string(), + options: vec![], + index: Some(4), + range: Some((-6.0, 6.0, 0.5)), + }, + ], + last_error: None, + last_error_time: None, + } + } + + pub fn set_error(&mut self, msg: String) { + self.last_error = Some(msg); + self.last_error_time = Some(std::time::Instant::now()); + } + + pub fn on_tick(&mut self) { + // Clear error after 5 seconds + if self.last_error_time.is_some_and(|time| time.elapsed() > std::time::Duration::from_secs(5)) { + self.last_error = None; + self.last_error_time = None; + } + } + + pub fn next_tab(&mut self) { + self.selected_tab = (self.selected_tab + 1) % self.tabs.len(); + } + + #[allow(dead_code)] + pub fn previous_tab(&mut self) { + if self.selected_tab > 0 { + self.selected_tab -= 1; + } else { + self.selected_tab = self.tabs.len() - 1; + } + } + + pub fn next_setting(&mut self) { + if let Some(selected) = self.settings_state.selected() { + let next = (selected + 1) % self.settings.len(); + self.settings_state.select(Some(next)); + } + } + + pub fn previous_setting(&mut self) { + if let Some(selected) = self.settings_state.selected() { + let next = if selected == 0 { self.settings.len() - 1 } else { selected - 1 }; + self.settings_state.select(Some(next)); + } + } + + pub fn update_setting(&mut self, key: String, val: String) { + for item in &mut self.settings { + if item.key == key { + // Normalize value (lowercase) + item.value = val.to_lowercase(); + } + } + } +} diff --git a/tui/src/bt.rs b/tui/src/bt.rs new file mode 100644 index 0000000..1ab3f97 --- /dev/null +++ b/tui/src/bt.rs @@ -0,0 +1,100 @@ +use std::time::Duration; + +use anyhow::Result; + +use bluer::{Adapter, Address, Device, Session}; +use bluer::rfcomm::{ProfileHandle, Role, ReqError, Stream, Profile}; + +use futures::StreamExt; + +const PIXEL_BUDS_CLASS: u32 = 0x240404; +const PIXEL_BUDS2_CLASS: u32 = 0x244404; + +/// Timeout for the entire RFCOMM connection process +const RFCOMM_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + + +pub async fn find_maestro_device(adapter: &Adapter) -> Result { + for addr in adapter.device_addresses().await? { + let dev = adapter.device(addr)?; + + let class = dev.class().await?.unwrap_or(0); + if class != PIXEL_BUDS_CLASS && class != PIXEL_BUDS2_CLASS { + continue; + } + + let uuids = dev.uuids().await?.unwrap_or_default(); + if !uuids.contains(&maestro::UUID) { + continue; + } + + tracing::debug!(address=%addr, "found compatible device"); + return Ok(dev); + } + + tracing::debug!("no compatible device found"); + anyhow::bail!("no compatible device found") +} + +pub async fn connect_maestro_rfcomm(session: &Session, dev: &Device) -> Result { + let maestro_profile = Profile { + uuid: maestro::UUID, + role: Some(Role::Client), + require_authentication: Some(false), + require_authorization: Some(false), + auto_connect: Some(false), + ..Default::default() + }; + + tracing::debug!("registering maestro profile"); + let mut handle = session.register_profile(maestro_profile).await?; + + tracing::debug!("connecting to maestro profile"); + + // Add timeout to prevent hanging when device disconnects during connection + let connect_future = async { + tokio::try_join!( + try_connect_profile(dev), + handle_requests_for_profile(&mut handle, dev.address()), + ) + }; + + let stream = tokio::time::timeout(RFCOMM_CONNECT_TIMEOUT, connect_future) + .await + .map_err(|_| anyhow::anyhow!("RFCOMM connection timed out"))??.1; + + Ok(stream) +} + +async fn try_connect_profile(dev: &Device) -> Result<()> { + const RETRY_TIMEOUT: Duration = Duration::from_secs(1); + const MAX_TRIES: u32 = 3; + + let mut i = 0; + while let Err(err) = dev.connect_profile(&maestro::UUID).await { + if i >= MAX_TRIES { return Err(err.into()) } + i += 1; + + tracing::debug!(error=?err, "connecting to profile failed, trying again ({}/{})", i, MAX_TRIES); + + tokio::time::sleep(RETRY_TIMEOUT).await; + } + + tracing::debug!(address=%dev.address(), "maestro profile connected"); + Ok(()) +} + +async fn handle_requests_for_profile(handle: &mut ProfileHandle, address: Address) -> Result { + while let Some(req) = handle.next().await { + tracing::debug!(address=%req.device(), "received new profile connection request"); + + if req.device() == address { + tracing::debug!(address=%req.device(), "accepting profile connection request"); + return Ok(req.accept()?); + } else { + req.reject(ReqError::Rejected); + } + } + + anyhow::bail!("profile terminated without requests") +} diff --git a/tui/src/maestro_client.rs b/tui/src/maestro_client.rs new file mode 100644 index 0000000..452d472 --- /dev/null +++ b/tui/src/maestro_client.rs @@ -0,0 +1,418 @@ +use std::time::Duration; +use tokio::sync::mpsc; +use anyhow::Result; +use futures::StreamExt; +use maestro::protocol::codec::Codec; +use maestro::protocol::utils; +use maestro::protocol::addr; +use maestro::pwrpc::client::Client; +use maestro::service::MaestroService; +use maestro::service::settings::{self, SettingValue, Setting}; +use maestro::protocol::types::RuntimeInfo as MRuntimeInfo; + +use crate::bt; + +/// Timeout for individual service commands +const COMMAND_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug, Clone, PartialEq)] +pub enum ConnectionState { + Disconnected, + Connected, +} + +#[derive(Debug, Clone, Default)] +pub struct BatteryState { + pub case_level: Option, + pub case_status: String, + pub left_level: Option, + pub left_status: String, + pub right_level: Option, + pub right_status: String, +} + +#[derive(Debug, Clone, Default)] +pub struct SoftwareInfo { + pub case_version: String, + pub left_version: String, + pub right_version: String, +} + +#[derive(Debug, Clone, Default)] +pub struct HardwareInfo { + pub case_serial: String, + pub left_serial: String, + pub right_serial: String, +} + +#[derive(Debug, Clone, Default)] +pub struct RuntimeInfo { + pub battery: BatteryState, + pub placement_left: String, + pub placement_right: String, + pub peer_local: String, + pub peer_remote: String, +} + +#[derive(Debug, Clone)] +pub enum ClientEvent { + ConnectionState(ConnectionState), + Software(SoftwareInfo), + Hardware(HardwareInfo), + Runtime(RuntimeInfo), + Setting(String, String), // key, value + Error(String), +} + +#[derive(Debug, Clone)] +pub enum ClientCommand { + CheckConnection, + GetSoftware, + GetHardware, + GetSetting(String), + SetSetting(String, String), +} + +pub async fn run_loop( + tx: mpsc::UnboundedSender, + mut rx: mpsc::UnboundedReceiver, +) { + let session = match bluer::Session::new().await { + Ok(s) => s, + Err(e) => { + let _ = tx.send(ClientEvent::Error(format!("Bluetooth session error: {}", e))); + return; + } + }; + + let adapter = match session.default_adapter().await { + Ok(a) => a, + Err(e) => { + let _ = tx.send(ClientEvent::Error(format!("Bluetooth adapter error: {}", e))); + return; + } + }; + + let _ = adapter.set_powered(true).await; + + loop { + // 1. Establish connection + let dev = match bt::find_maestro_device(&adapter).await { + Ok(d) => d, + Err(_) => { + tokio::time::sleep(Duration::from_secs(2)).await; + if let Ok(_cmd) = rx.try_recv() { + // process minimal commands? + } + continue; + } + }; + + let stream = match bt::connect_maestro_rfcomm(&session, &dev).await { + Ok(s) => s, + Err(e) => { + // Only send error if it's not a transient connection failure + let err_str = e.to_string(); + if !err_str.contains("not available") && !err_str.contains("not connected") { + let _ = tx.send(ClientEvent::Error(format!("Connection failed: {}", e))); + } + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; + + let codec = Codec::new(); + let stream = codec.wrap(stream); + let mut client = Client::new(stream); + let handle = client.handle(); + + let channel_res = tokio::time::timeout( + Duration::from_secs(10), + utils::resolve_channel(&mut client) + ).await; + + let channel = match channel_res { + Ok(Ok(c)) => c, + Ok(Err(e)) => { + let _ = tx.send(ClientEvent::Error(format!("Channel resolution failed: {}", e))); + continue; + } + Err(_) => { + let _ = tx.send(ClientEvent::Error("Channel resolution timed out".to_string())); + continue; + } + }; + + let mut service = MaestroService::new(handle.clone(), channel); + let _ = tx.send(ClientEvent::ConnectionState(ConnectionState::Connected)); + + // Subscribe to changes. + let mut settings_sub = match service.subscribe_to_settings_changes() { + Ok(call) => Some(call), + Err(e) => { + let _ = tx.send(ClientEvent::Error(format!("Settings sub failed: {}", e))); + None + } + }; + + let mut runtime_sub = match service.subscribe_to_runtime_info() { + Ok(call) => Some(call), + Err(e) => { + let _ = tx.send(ClientEvent::Error(format!("Runtime sub failed: {}", e))); + None + } + }; + + // Spawn client run loop to ensure packet processing happens concurrently with command handling + let mut client_task = tokio::spawn(async move { client.run().await }); + + // Inner loop: Connected state + loop { + tokio::select! { + res = &mut client_task => { + // Client task finished (error or disconnect) + // Clear subscriptions immediately to prevent blocking + settings_sub = None; + runtime_sub = None; + + let _ = tx.send(ClientEvent::ConnectionState(ConnectionState::Disconnected)); + match res { + Ok(Err(e)) => { + // Only send error if it's not a connection reset (expected on disconnect) + let err_str = e.to_string(); + if !err_str.contains("connection reset") && !err_str.contains("os error 104") { + let _ = tx.send(ClientEvent::Error(format!("Client error: {}", e))); + } + } + Ok(Ok(_)) => {} // Clean exit? + Err(e) => { let _ = tx.send(ClientEvent::Error(format!("Client task join error: {}", e))); } + } + break; + } + + cmd = rx.recv() => { + match cmd { + Some(c) => handle_command(c, &mut service, &tx).await, + None => return, + } + } + + Some(res) = async { settings_sub.as_mut()?.stream().next().await }, if settings_sub.is_some() => { + match res { + Ok(rsp) => { + if let Some(val) = rsp.value_oneof { + use maestro::protocol::types::settings_rsp; + let settings_rsp::ValueOneof::Value(sv) = val; + // sv is types::SettingValue + if let Some(vo) = sv.value_oneof { + let setting: SettingValue = vo.into(); + process_setting_change(setting, &tx); + } + } + } + Err(_) => { + // Settings subscription error, clear it to prevent blocking + settings_sub = None; + } + } + } + + Some(res) = async { runtime_sub.as_mut()?.stream().next().await }, if runtime_sub.is_some() => { + match res { + Ok(info) => { + let r_info = convert_runtime_info(info, channel); + let _ = tx.send(ClientEvent::Runtime(r_info)); + } + Err(_) => { + // Runtime subscription error, clear it to prevent blocking + runtime_sub = None; + } + } + } + } + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } +} + +async fn handle_command(cmd: ClientCommand, service: &mut MaestroService, tx: &mpsc::UnboundedSender) { + match cmd { + ClientCommand::CheckConnection => { + let _ = tx.send(ClientEvent::ConnectionState(ConnectionState::Connected)); + } + ClientCommand::GetSoftware => { + let result = tokio::time::timeout( + COMMAND_TIMEOUT, + service.get_software_info() + ).await; + + match result { + Ok(Ok(info)) => { + let sw = SoftwareInfo { + case_version: info.firmware.as_ref().and_then(|f| f.case.as_ref()).map(|v| v.version_string.clone()).unwrap_or_default(), + left_version: info.firmware.as_ref().and_then(|f| f.left.as_ref()).map(|v| v.version_string.clone()).unwrap_or_default(), + right_version: info.firmware.as_ref().and_then(|f| f.right.as_ref()).map(|v| v.version_string.clone()).unwrap_or_default(), + }; + let _ = tx.send(ClientEvent::Software(sw)); + } + Ok(Err(e)) => { let _ = tx.send(ClientEvent::Error(format!("GetSoftware failed: {}", e))); } + Err(_) => { let _ = tx.send(ClientEvent::Error("GetSoftware timed out".to_string())); } + } + } + ClientCommand::GetHardware => { + let result = tokio::time::timeout( + COMMAND_TIMEOUT, + service.get_hardware_info() + ).await; + + match result { + Ok(Ok(info)) => { + let hw = HardwareInfo { + case_serial: info.serial_number.as_ref().map(|s| s.case.clone()).unwrap_or_default(), + left_serial: info.serial_number.as_ref().map(|s| s.left.clone()).unwrap_or_default(), + right_serial: info.serial_number.as_ref().map(|s| s.right.clone()).unwrap_or_default(), + }; + let _ = tx.send(ClientEvent::Hardware(hw)); + } + Ok(Err(e)) => { let _ = tx.send(ClientEvent::Error(format!("GetHardware failed: {}", e))); } + Err(_) => { let _ = tx.send(ClientEvent::Error("GetHardware timed out".to_string())); } + } + } + ClientCommand::GetSetting(key) => { + let res = match key.as_str() { + "anc" => read_and_send(service, settings::id::CurrentAncrState, &key, tx).await, + "volume-eq" => read_and_send(service, settings::id::VolumeEqEnable, &key, tx).await, + "mono" => read_and_send(service, settings::id::SumToMono, &key, tx).await, + "speech-detection" => read_and_send(service, settings::id::SpeechDetection, &key, tx).await, + "multipoint" => read_and_send(service, settings::id::MultipointEnable, &key, tx).await, + "ohd" => read_and_send(service, settings::id::OhdEnable, &key, tx).await, + "gestures" => read_and_send(service, settings::id::GestureEnable, &key, tx).await, + "volume-exposure-notifications" => read_and_send(service, settings::id::VolumeExposureNotifications, &key, tx).await, + "diagnostics" => read_and_send(service, settings::id::DiagnosticsEnable, &key, tx).await, + "oobe-mode" => read_and_send(service, settings::id::OobeMode, &key, tx).await, + "oobe-is-finished" => read_and_send(service, settings::id::OobeIsFinished, &key, tx).await, + "balance" => read_and_send(service, settings::id::VolumeAsymmetry, &key, tx).await, + "eq" => read_and_send(service, settings::id::CurrentUserEq, &key, tx).await, + "gesture-control" => read_and_send(service, settings::id::GestureControl, &key, tx).await, + _ => Ok(()), + }; + if let Err(e) = res { + let _ = tx.send(ClientEvent::Error(format!("Get {} failed: {}", key, e))); + } + } + ClientCommand::SetSetting(key, val) => { + let write_result = async { + match key.as_str() { + "anc" => { + let state = match val.as_str() { + "active" => settings::AncState::Active, + "aware" => settings::AncState::Aware, + "off" => settings::AncState::Off, + "adaptive" => settings::AncState::Adaptive, + _ => settings::AncState::Off, + }; + service.write_setting(SettingValue::CurrentAncrState(state)).await + }, + "volume-eq" => service.write_setting(SettingValue::VolumeEqEnable(val == "true")).await, + "mono" => service.write_setting(SettingValue::SumToMono(val == "true")).await, + "speech-detection" => service.write_setting(SettingValue::SpeechDetection(val == "true")).await, + "multipoint" => service.write_setting(SettingValue::MultipointEnable(val == "true")).await, + "ohd" => service.write_setting(SettingValue::OhdEnable(val == "true")).await, + "gestures" => service.write_setting(SettingValue::GestureEnable(val == "true")).await, + "volume-exposure-notifications" => service.write_setting(SettingValue::VolumeExposureNotifications(val == "true")).await, + "diagnostics" => service.write_setting(SettingValue::DiagnosticsEnable(val == "true")).await, + "oobe-mode" => service.write_setting(SettingValue::OobeMode(val == "true")).await, + "oobe-is-finished" => service.write_setting(SettingValue::OobeIsFinished(val == "true")).await, + "balance" => { + if let Ok(n) = val.parse::() { + let va = settings::VolumeAsymmetry::from_normalized(n); + service.write_setting(SettingValue::VolumeAsymmetry(va)).await + } else { + Ok(()) + } + }, + "eq" => { + let parts: Vec = val.split_whitespace().filter_map(|s| s.parse().ok()).collect(); + if parts.len() == 5 { + let eq = settings::EqBands::new(parts[0], parts[1], parts[2], parts[3], parts[4]); + service.write_setting(SettingValue::CurrentUserEq(eq)).await + } else { + Ok(()) + } + }, + _ => Ok(()), + } + }; + + match tokio::time::timeout(COMMAND_TIMEOUT, write_result).await { + Ok(Ok(())) => {} + Ok(Err(e)) => { let _ = tx.send(ClientEvent::Error(format!("Set {} failed: {}", key, e))); } + Err(_) => { let _ = tx.send(ClientEvent::Error(format!("Set {} timed out", key))); } + } + } + } +} + +async fn read_and_send(service: &mut MaestroService, setting: T, key: &str, tx: &mpsc::UnboundedSender) -> Result<(), maestro::pwrpc::Error> +where T: Setting, T::Type: std::fmt::Display { + let result = tokio::time::timeout( + COMMAND_TIMEOUT, + service.read_setting(setting) + ).await; + + match result { + Ok(Ok(val)) => { + let _ = tx.send(ClientEvent::Setting(key.to_string(), val.to_string())); + Ok(()) + } + Ok(Err(e)) => Err(e), + Err(_) => { + let _ = tx.send(ClientEvent::Error(format!("Get {} timed out", key))); + Ok(()) + } + } +} + +fn process_setting_change(setting: SettingValue, tx: &mpsc::UnboundedSender) { + let (key, val) = match setting { + SettingValue::CurrentAncrState(s) => ("anc", s.to_string()), + SettingValue::VolumeEqEnable(b) => ("volume-eq", b.to_string()), + SettingValue::SumToMono(b) => ("mono", b.to_string()), + SettingValue::SpeechDetection(b) => ("speech-detection", b.to_string()), + SettingValue::MultipointEnable(b) => ("multipoint", b.to_string()), + SettingValue::OhdEnable(b) => ("ohd", b.to_string()), + SettingValue::GestureEnable(b) => ("gestures", b.to_string()), + SettingValue::VolumeExposureNotifications(b) => ("volume-exposure-notifications", b.to_string()), + SettingValue::DiagnosticsEnable(b) => ("diagnostics", b.to_string()), + SettingValue::OobeMode(b) => ("oobe-mode", b.to_string()), + SettingValue::OobeIsFinished(b) => ("oobe-is-finished", b.to_string()), + SettingValue::VolumeAsymmetry(va) => ("balance", va.to_string()), + SettingValue::CurrentUserEq(eq) => ("eq", eq.to_string()), + SettingValue::GestureControl(gc) => ("gesture-control", format!("{:?}", gc)), + _ => return, + }; + + let _ = tx.send(ClientEvent::Setting(key.to_string(), val.to_lowercase())); +} + +fn convert_runtime_info(info: MRuntimeInfo, channel: u32) -> RuntimeInfo { + let address = addr::address_for_channel(channel); + let peer_local = address.map(|a| format!("{:?}", a.source())).unwrap_or_else(|| "unknown".to_string()); + let peer_remote = address.map(|a| format!("{:?}", a.target())).unwrap_or_else(|| "unknown".to_string()); + + RuntimeInfo { + battery: BatteryState { + case_level: info.battery_info.as_ref().and_then(|b| b.case.as_ref()).map(|b| b.level as u8), + case_status: info.battery_info.as_ref().and_then(|b| b.case.as_ref()).map(|b| if b.state == 2 { "charging" } else { "not charging" }).unwrap_or("unknown").to_string(), + left_level: info.battery_info.as_ref().and_then(|b| b.left.as_ref()).map(|b| b.level as u8), + left_status: info.battery_info.as_ref().and_then(|b| b.left.as_ref()).map(|b| if b.state == 2 { "charging" } else { "not charging" }).unwrap_or("unknown").to_string(), + right_level: info.battery_info.as_ref().and_then(|b| b.right.as_ref()).map(|b| b.level as u8), + right_status: info.battery_info.as_ref().and_then(|b| b.right.as_ref()).map(|b| if b.state == 2 { "charging" } else { "not charging" }).unwrap_or("unknown").to_string(), + }, + placement_left: info.placement.as_ref().map(|p| if p.left_bud_in_case { "in case" } else { "out of case" }).unwrap_or("unknown").to_string(), + placement_right: info.placement.as_ref().map(|p| if p.right_bud_in_case { "in case" } else { "out of case" }).unwrap_or("unknown").to_string(), + peer_local, + peer_remote, + } +} diff --git a/tui/src/main.rs b/tui/src/main.rs new file mode 100644 index 0000000..e54b67a --- /dev/null +++ b/tui/src/main.rs @@ -0,0 +1,280 @@ +use std::{io, time::Duration}; +use anyhow::Result; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use tokio::sync::mpsc; + +mod app; +mod bt; +mod maestro_client; +mod ui; + +use app::App; +use maestro_client::{ClientCommand, ClientEvent}; + +#[tokio::main(flavor = "multi_thread", worker_threads = 2)] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_writer(std::io::stderr) + .init(); + + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create app + let mut app = App::new(); + + // Create channels + let (tx_event, rx_event) = mpsc::unbounded_channel(); + let (tx_cmd, rx_cmd) = mpsc::unbounded_channel(); + + // Spawn client in a separate blocking task to isolate it completely + tokio::spawn(maestro_client::run_loop(tx_event, rx_cmd)); + + // Initial check + tx_cmd.send(ClientCommand::CheckConnection).ok(); + + let res = run_app(&mut terminal, &mut app, tx_cmd, rx_event).await; + + // Restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{:?}", err); + } + + Ok(()) +} + +async fn run_app( + terminal: &mut Terminal, + app: &mut App, + tx_cmd: mpsc::UnboundedSender, + mut rx_event: mpsc::UnboundedReceiver, +) -> Result<()> +where + ::Error: std::marker::Send + Sync + 'static, +{ + let tick_rate = Duration::from_millis(50); + + loop { + terminal.draw(|f| ui::draw(f, app))?; + + // Poll for keyboard events with timeout - this is non-blocking + if event::poll(tick_rate)? && let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char('q') => { + app.should_quit = true; + } + KeyCode::Tab => { + app.next_tab(); + } + KeyCode::Char('c') => { + tx_cmd.send(ClientCommand::CheckConnection).ok(); + } + KeyCode::Down | KeyCode::Char('j') => { + if app.selected_tab == 1 { + app.next_setting(); + } + } + KeyCode::Up | KeyCode::Char('k') => { + if app.selected_tab == 1 { + app.previous_setting(); + } + } + KeyCode::Left => { + if app.selected_tab == 1 { + handle_numeric_change(app, &tx_cmd, -1.0); + } + } + KeyCode::Right => { + if app.selected_tab == 1 { + handle_numeric_change(app, &tx_cmd, 1.0); + } + } + KeyCode::Enter => { + if app.selected_tab == 1 { + handle_setting_change(app, &tx_cmd); + } + } + _ => {} + } + } + + // Process all pending client events (non-blocking) + while let Ok(event) = rx_event.try_recv() { + process_client_event(app, &tx_cmd, event)?; + } + + app.on_tick(); + + if app.should_quit { + return Ok(()); + } + } +} + +fn process_client_event( + app: &mut App, + tx_cmd: &mpsc::UnboundedSender, + event: ClientEvent, +) -> Result<()> { + match event { + ClientEvent::ConnectionState(state) => { + app.connection_state = state.clone(); + if matches!(state, maestro_client::ConnectionState::Connected) { + tx_cmd.send(ClientCommand::GetSoftware)?; + tx_cmd.send(ClientCommand::GetHardware)?; + + let mut fetched_keys = std::collections::HashSet::new(); + for item in &app.settings { + if !fetched_keys.contains(&item.key) { + tx_cmd.send(ClientCommand::GetSetting(item.key.clone()))?; + fetched_keys.insert(item.key.clone()); + } + } + tx_cmd.send(ClientCommand::GetSetting("gesture-control".to_string()))?; + } + } + ClientEvent::Software(info) => { + app.software = info; + } + ClientEvent::Hardware(info) => { + app.hardware = info; + } + ClientEvent::Runtime(info) => { + app.runtime = info.clone(); + app.battery = info.battery; + } + ClientEvent::Setting(key, val) => { + if key == "gesture-control" { + app.gesture_control = val; + } else if key == "eq" { + let trimmed = val.trim_matches(|c| c == '[' || c == ']'); + let parts: Vec<&str> = trimmed.split(',').map(|s| s.trim()).collect(); + + if parts.len() == 5 { + for (i, part) in parts.iter().enumerate() { + if part.parse::().is_ok() + && let Some(item) = app.settings.iter_mut() + .find(|it| it.key == "eq" && it.index == Some(i)) + { + item.value = part.to_string(); + } + } + } + } else { + app.update_setting(key, val); + } + } + ClientEvent::Error(msg) => { + app.set_error(msg); + } + } + Ok(()) +} + +fn handle_numeric_change(app: &mut App, tx_cmd: &mpsc::UnboundedSender, direction: f32) { + if let Some(idx) = app.settings_state.selected() { + let item = &mut app.settings[idx]; + + if let Some((min, max, step)) = item.range { + let change = direction * step; + + if item.key == "balance" { + // Parse current balance + let current_val = if item.value.contains("left:") { + let parts: Vec<&str> = item.value.split(',').collect(); + let mut l = 100; + let mut r = 100; + + for part in parts { + if let Some(v) = part.split(':').nth(1) { + let n = v.trim().trim_end_matches('%').parse::().unwrap_or(100); + if part.contains("left") { l = n; } + if part.contains("right") { r = n; } + } + } + + if r == 100 { + 100 - l + } else { + r - 100 + } + } else { + 0 + }; + + let new_val = (current_val as f32 + change).clamp(min, max) as i32; + + let l = (100 - new_val).min(100); + let r = (100 + new_val).min(100); + item.value = format!("left: {}%, right: {}%", l, r); + + let _ = tx_cmd.send(ClientCommand::SetSetting(item.key.clone(), new_val.to_string())); + + } else if item.key == "eq" { + let current_val = item.value.parse::().unwrap_or(0.0); + let new_val = (current_val + change).clamp(min, max); + + item.value = format!("{:.2}", new_val); + } + } + } + + // Split logic to avoid borrow checker issues for EQ + if let Some(idx) = app.settings_state.selected() { + let key = app.settings[idx].key.clone(); + if key == "eq" { + let mut eq_values = [0.0f32; 5]; + for it in &app.settings { + if it.key == "eq" + && let Some(i) = it.index + && i < 5 { + eq_values[i] = it.value.parse::().unwrap_or(0.0); + } + } + + let args = eq_values.iter().map(|v| format!("{:.2}", v)).collect::>().join(" "); + let _ = tx_cmd.send(ClientCommand::SetSetting("eq".to_string(), args)); + } + } +} + +fn handle_setting_change(app: &App, tx_cmd: &mpsc::UnboundedSender) { + if let Some(idx) = app.settings_state.selected() { + let item = &app.settings[idx]; + + if item.options.is_empty() { + return; + } + + let current_val = item.value.to_lowercase(); + let current_opt_idx = item.options.iter().position(|o| o.to_lowercase() == current_val); + + let next_val = if let Some(i) = current_opt_idx { + item.options[(i + 1) % item.options.len()].clone() + } else if !item.options.is_empty() { + item.options[0].clone() + } else { + return; + }; + + let _ = tx_cmd.send(ClientCommand::SetSetting(item.key.clone(), next_val)); + } +} diff --git a/tui/src/ui.rs b/tui/src/ui.rs new file mode 100644 index 0000000..404a3c5 --- /dev/null +++ b/tui/src/ui.rs @@ -0,0 +1,239 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect, Alignment}, + style::{Color, Style, Modifier}, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Tabs, Row, Table}, + Frame, +}; +use crate::app::App; +use crate::maestro_client::ConnectionState; + +pub fn draw(f: &mut Frame, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Tabs + Constraint::Min(0), // Content + Constraint::Length(1), // Status/Help + ]) + .split(f.area()); + + draw_tabs(f, app, chunks[0]); + + match app.selected_tab { + 0 => draw_status(f, app, chunks[1]), + 1 => draw_settings(f, app, chunks[1]), + _ => {}, + } + + draw_help(f, app, chunks[2]); +} + +fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { + let titles: Vec = app + .tabs + .iter() + .map(|t| Line::from(Span::styled(t, Style::default().fg(Color::Green)))) + .collect(); + + let tabs = Tabs::new(titles) + .block(Block::default().borders(Borders::ALL).title("Pixel Buds Pro Control")) + .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) + .select(app.selected_tab); + + f.render_widget(tabs, area); +} + +fn draw_status(f: &mut Frame, app: &App, area: Rect) { + let main_layout = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), // Connection Header + Constraint::Min(0), // Main Content + ]) + .split(area); + + // Connection Status + let (conn_text, conn_style) = match app.connection_state { + ConnectionState::Disconnected => ( + "Disconnected (Press 'c' to refresh/connect)", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + ), + ConnectionState::Connected => ( + "Connected", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + ), + }; + + let p = Paragraph::new(conn_text) + .style(conn_style) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))); + f.render_widget(p, main_layout[0]); + + if app.connection_state == ConnectionState::Disconnected { + return; + } + + let content_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(55), // Device & Placement Info + Constraint::Percentage(45), // Battery Info + ]) + .split(main_layout[1]); + + // Left Column: Device Info + Placement + let left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .split(content_layout[0]); + + // Device Info Table + let rows = vec![ + Row::new(vec!["Component", "Firmware", "Serial Number"]).style(Style::default().fg(Color::Yellow)), + Row::new(vec!["Case", &app.software.case_version, &app.hardware.case_serial]), + Row::new(vec!["Left Bud", &app.software.left_version, &app.hardware.left_serial]), + Row::new(vec!["Right Bud", &app.software.right_version, &app.hardware.right_serial]), + ]; + + let table = Table::new(rows, [ + Constraint::Percentage(30), + Constraint::Percentage(35), + Constraint::Percentage(35) + ]) + .block(Block::default().title(" Device Information ").borders(Borders::ALL)) + .column_spacing(1); + + f.render_widget(table, left_chunks[0]); + + // Placement & Connection Table + let place_rows = vec![ + Row::new(vec!["Left Placement", &app.runtime.placement_left]), + Row::new(vec!["Right Placement", &app.runtime.placement_right]), + Row::new(vec!["Local Peer", &app.runtime.peer_local]), + Row::new(vec!["Remote Peer", &app.runtime.peer_remote]), + Row::new(vec!["ANC Status", get_setting_val(app, "anc")]), + Row::new(vec!["Multipoint", get_setting_val(app, "multipoint")]), + Row::new(vec!["In-Ear Detection", get_setting_val(app, "ohd")]), + Row::new(vec!["Hold Gestures", &app.gesture_control]), + ]; + let place_table = Table::new(place_rows, [ + Constraint::Percentage(40), + Constraint::Percentage(60), + ]) + .block(Block::default().title(" Status & Connection ").borders(Borders::ALL)) + .column_spacing(1); + f.render_widget(place_table, left_chunks[1]); + + + // Battery Info - Custom Layout + // We only show Case if known + let mut constraints = vec![]; + let show_case = app.battery.case_level.is_some(); + + // Calculate space needed for each battery item (Text + Gauge) + // We give them 3 lines each: 1 for Text, 1 for Gauge, 1 padding/border + // Actually let's do: + // Case: + // Level: 100% (Charging) + // [||||||||||] + + if show_case { + constraints.push(Constraint::Length(4)); + } + constraints.push(Constraint::Length(4)); // Left + constraints.push(Constraint::Length(4)); // Right + constraints.push(Constraint::Min(0)); // Spacer + + let bat_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .margin(1) + .split(content_layout[1]); + + let bat_block = Block::default().title(" Battery Status ").borders(Borders::ALL); + f.render_widget(bat_block, content_layout[1]); + + let mut current_chunk_idx = 0; + + if show_case { + draw_battery_item(f, bat_chunks[current_chunk_idx], "Case", app.battery.case_level, &app.battery.case_status); + current_chunk_idx += 1; + } + + draw_battery_item(f, bat_chunks[current_chunk_idx], "Left Bud", app.battery.left_level, &app.battery.left_status); + current_chunk_idx += 1; + + draw_battery_item(f, bat_chunks[current_chunk_idx], "Right Bud", app.battery.right_level, &app.battery.right_status); +} + +fn get_setting_val<'a>(app: &'a App, key: &str) -> &'a str { + app.settings.iter().find(|s| s.key == key).map(|s| s.value.as_str()).unwrap_or("Unknown") +} + +fn draw_battery_item(f: &mut Frame, area: Rect, name: &str, level: Option, status: &str) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)]) + .split(area); + + let charging = status.contains("charging") && !status.contains("not charging"); + let level_val = level.unwrap_or(0); + let color = if charging { Color::Green } else if level_val < 20 { Color::Red } else { Color::Cyan }; + + let status_text = if let Some(l) = level { + format!("{} {}% ({})", name, l, status) + } else { + format!("{} Unknown ({})", name, status) + }; + + let text = Paragraph::new(status_text).style(Style::default().add_modifier(Modifier::BOLD)); + f.render_widget(text, chunks[0]); + + let gauge = Gauge::default() + .gauge_style(Style::default().fg(color)) + .ratio(level_val as f64 / 100.0) + .label(""); // No label on gauge itself as requested + + f.render_widget(gauge, chunks[1]); +} + +fn draw_settings(f: &mut Frame, app: &mut App, area: Rect) { + let items: Vec = app.settings.iter().map(|i| { + let val_str = i.value.clone(); + + let content = Line::from(vec![ + Span::styled(format!("{:<40}", i.name), Style::default().fg(Color::White)), + Span::styled(val_str, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + ]); + + ListItem::new(content) + }).collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" Settings ")) + .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) + .highlight_symbol(">> "); + + f.render_stateful_widget(list, area, &mut app.settings_state); +} + +fn draw_help(f: &mut Frame, app: &App, area: Rect) { + if let Some(err) = &app.last_error { + let p = Paragraph::new(format!("Error: {}", err)) + .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) + .alignment(Alignment::Center); + f.render_widget(p, area); + } else { + let text = "q: Quit | Tab: Switch Tab | c: Check Connection/Refresh | Enter: Toggle/Change Setting"; + let p = Paragraph::new(text) + .style(Style::default().fg(Color::Gray)) + .alignment(Alignment::Center); + f.render_widget(p, area); + } +}