diff --git a/Cargo.lock b/Cargo.lock index fd48dcb1..49e96b04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,35 @@ 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", + "tokio", + "tokio-stream", + "uuid", +] + [[package]] name = "borsh" version = "1.8.1" @@ -84,6 +113,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -159,6 +194,96 @@ dependencies = [ "typenum", ] +[[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.119", + "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.119", +] + +[[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.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + [[package]] name = "digest" version = "0.10.7" @@ -170,6 +295,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -210,6 +346,94 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -292,6 +516,24 @@ dependencies = [ "stable_deref_trait", ] +[[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.14.2" @@ -302,12 +544,44 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -323,12 +597,58 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -344,6 +664,38 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "postcard" version = "1.1.3" @@ -508,6 +860,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "rusty-fork" version = "0.3.1" @@ -562,6 +920,45 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "spin" version = "0.9.9" @@ -577,6 +974,34 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -605,6 +1030,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -638,6 +1074,17 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "tines-bt" +version = "0.3.0" +dependencies = [ + "bluer", + "futures", + "log", + "tines-core", + "tokio", +] + [[package]] name = "tines-core" version = "0.3.0" @@ -666,6 +1113,44 @@ dependencies = [ "tines-core", ] +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.2+spec-1.1.0" @@ -714,6 +1199,17 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -735,6 +1231,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -778,3 +1319,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index f46241d8..4f3cec22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["tines-core", "tines-sim", "tines-store"] +members = ["tines-core", "tines-sim", "tines-store", "tines-bt"] resolver = "2" [workspace.package] diff --git a/tines-bt/Cargo.toml b/tines-bt/Cargo.toml new file mode 100644 index 00000000..62ff70f6 --- /dev/null +++ b/tines-bt/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "tines-bt" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "BLE transport for tines over BlueZ via the bluer crate: identity-preserving (Addr = NodeId) messaging with testable radio I/O." +authors.workspace = true +repository.workspace = true +readme.workspace = true +documentation = "https://docs.rs/tines-bt" +keywords = ["robotics", "gossip", "delay-tolerant", "ble", "bluer"] +categories = ["science::robotics", "network-programming"] + +[dependencies] +tines-core = { path = "../tines-core", version = "0.3.0" } +bluer = { version = "0.17", features = ["bluetoothd", "l2cap"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "sync", "net", "time", "signal"] } +futures = "0.3" +log = "0.4" diff --git a/tines-bt/src/adv.rs b/tines-bt/src/adv.rs new file mode 100644 index 00000000..476aa4ec --- /dev/null +++ b/tines-bt/src/adv.rs @@ -0,0 +1,166 @@ +//! BLE advertising payload assembly for tines-bt. +//! +//! The advertised payload carries just enough for a peer to (a) recognise a +//! tines device and (b) learn our identity *without* opening a connection: a +//! standard LE flags record followed by a "Service Data — 16-bit UUID" record +//! that embeds a private 16-bit UUID and our 16-byte [`NodeId`]. +//! +//! A 16-bit UUID fits the legacy 31-byte extended-in-data (EIR) advertising +//! limit; a 128-bit UUID would not (39 bytes). Decoding the [`NodeId`] back out +//! of the service-data record is exactly what [`decode_node_id`] does — the +//! invariant the round-trip test pins. + +use tines_core::observation::NodeId; + +/// The LE flags byte carried in the flags AD record: LE General Available and +/// BR/EDR not supported (`0x06`). +pub const ADV_FLAGS: u8 = 0x06; + +/// AD type: Flags (`0x01`). +const AD_TYPE_FLAGS: u8 = 0x01; + +/// AD type: Service Data — 16-bit UUID (`0x16`). +const AD_TYPE_SERVICE_DATA_16: u8 = 0x16; + +/// Private-use 16-bit service UUID advertised in the service-data record. +/// +/// Not a SIG-assigned UUID; any private value works because tines peers agree +/// on it out of band. Chosen to avoid common Nordic/nRF service UUIDs. +pub const SERVICE_UUID: u16 = 0xFE7E; + +/// Number of bytes a tines advertising buffer holds. +/// +/// Matches the legacy BLE extended-in-data maximum so this crate advertises +/// within the 31-byte legacy advertising limit rather than requiring +/// LE Advertising (V2) extended advertising. +pub const ADVERTISING_BUFFER_LEN: usize = 31; + +/// Length, in bytes, of a [`NodeId`]. +pub const NODE_ID_LEN: usize = 16; + +/// Assemble the legacy 31-byte BLE advertising buffer that advertises `node` +/// (this device's [`NodeId`]). +/// +/// The buffer is the standard EIR layout: +/// +/// ```text +/// [0x02 0x01 0x06] flags: LE available, no BR/EDR +/// [0x13 0x16 service data, 16-bit UUID + NodeId +/// +/// ``` +/// +/// The meaningful bytes occupy the first 23 bytes; the remainder is zero +/// padding. The returned buffer is always exactly +/// [`ADVERTISING_BUFFER_LEN`] bytes and always fits the legacy advertising +/// limit. +pub fn advertise(node: NodeId) -> [u8; ADVERTISING_BUFFER_LEN] { + let mut buf = [0u8; ADVERTISING_BUFFER_LEN]; + + // Flags AD record: length (2 bytes follow), type (0x01), data (0x06). + buf[0] = 2; + buf[1] = AD_TYPE_FLAGS; + buf[2] = ADV_FLAGS; + + // Service Data — 16-bit UUID record. The payload after the length byte is + // type (0x16) + 2-byte UUID + 16-byte NodeId = 19 bytes; the length byte is + // the count of the bytes that follow it. + let uuid_le = SERVICE_UUID.to_le_bytes(); + buf[3] = 1 + 2 + NODE_ID_LEN as u8; // 19 + buf[4] = AD_TYPE_SERVICE_DATA_16; // 0x16 + buf[5] = uuid_le[0]; + buf[6] = uuid_le[1]; + buf[7..7 + NODE_ID_LEN].copy_from_slice(&node); + + buf +} + +/// Decode the [`NodeId`] advertised in a raw EIR buffer. +/// +/// Walks the AD records looking for a "Service Data — 16-bit UUID" record +/// (`0x16`), skips its 2-byte UUID, and returns the following 16 bytes as the +/// [`NodeId`]. Returns `None` if no such record is present or it is malformed. +/// +/// This is generic over the total buffer length — it works whether the +/// meaningful data is 23 bytes or any other length `<= ADVERTISING_BUFFER_LEN` +/// — which is what makes the round-trip test robust to the exact padding. +/// +/// [`NodeId`]: tines_core::observation::NodeId +pub fn decode_node_id(payload: &[u8]) -> Option { + let mut i = 0; + while i < payload.len() { + let len = payload[i] as usize; + if len == 0 { + break; + } + // record = [len][type][data...len]; guard against overrun. + let end = i.checked_add(1 + len)?; + if end > payload.len() { + break; + } + let ad_type = payload[i + 1]; + let data = &payload[i + 2..i + 1 + len]; + if ad_type == AD_TYPE_SERVICE_DATA_16 && data.len() >= 2 + NODE_ID_LEN { + let mut node = [0u8; NODE_ID_LEN]; + node.copy_from_slice(&data[2..2 + NODE_ID_LEN]); + return Some(node); + } + i = end; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advertises_flags_then_service_data() { + let p = advertise([3u8; 16]); + assert_eq!(p.len(), ADVERTISING_BUFFER_LEN); + assert_eq!((p[0], p[1], p[2]), (2, AD_TYPE_FLAGS, ADV_FLAGS)); + assert_eq!( + (p[3], p[4]), + (1 + 2 + NODE_ID_LEN as u8, AD_TYPE_SERVICE_DATA_16) + ); + } + + #[test] + fn meaningful_bytes_fit_legacy_limit() { + let p = advertise([9u8; 16]); + let last = p.iter().rposition(|&b| b != 0).expect("payload has data"); + assert_eq!(last, 22); + assert_eq!(&p[23..], &[0u8; 8]); + } + + #[test] + fn service_uuid_is_little_endian() { + let p = advertise([0u8; 16]); + let uuid_le = [p[5], p[6]]; + assert_eq!(u16::from_le_bytes(uuid_le), SERVICE_UUID); + } + + #[test] + fn roundtrips_node_id() { + let node: NodeId = [ + 0xde, 0xad, 0xbe, 0xef, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, + 0xaa, 0xbb, + ]; + assert_eq!(decode_node_id(&advertise(node)), Some(node)); + } + + #[test] + fn missing_service_data_returns_none() { + let mut payload = [0u8; ADVERTISING_BUFFER_LEN]; + // A lone flags record, no service-data record. + payload[0] = 2; + payload[1] = AD_TYPE_FLAGS; + payload[2] = ADV_FLAGS; + assert_eq!(decode_node_id(&payload), None); + } + + #[test] + fn node_id_zero_is_roundtripped() { + // All-zero node id must still round-trip (must not be read as empty). + assert_eq!(decode_node_id(&advertise([0u8; 16])), Some([0u8; 16])); + } +} diff --git a/tines-bt/src/lib.rs b/tines-bt/src/lib.rs new file mode 100644 index 00000000..84e6e1a6 --- /dev/null +++ b/tines-bt/src/lib.rs @@ -0,0 +1,21 @@ +//! tines-bt: a BLE `Transport` for tines over BlueZ, via the `bluer` crate. +//! +//! BLE radio addresses rotate and are not a stable identity, so `Addr` on this +//! transport is the peer's advertised `NodeId` (`tines_core::observation::NodeId`). +//! The advertising payload (see [`adv`]) carries that `NodeId` in service data, +//! so identity survives address rotation. +//! +//! Real radio I/O lives behind the small [`Radio`] trait (implemented over +//! `bluer` in [`transport::BluerRadio`]); the pure logic — advertising payload +//! assembly, the from/`Addr` mismatch-drop rule, and the central-peripheral +//! role decision — is unit-testable without hardware. + +#![warn(missing_docs)] + +pub mod adv; +mod transport; + +pub use adv::{ + advertise, decode_node_id, ADVERTISING_BUFFER_LEN, ADV_FLAGS, NODE_ID_LEN, SERVICE_UUID, +}; +pub use transport::{is_central, BluerRadio, BtTransport, Radio, RadioError, RadioEvent}; diff --git a/tines-bt/src/transport.rs b/tines-bt/src/transport.rs new file mode 100644 index 00000000..808fb627 --- /dev/null +++ b/tines-bt/src/transport.rs @@ -0,0 +1,385 @@ +//! [`BtTransport`]: a [`Transport`] implementation over BLE, with identity +//! (the peer's advertised [`NodeId`]) surviving BLE address rotation. +//! +//! Real radio I/O sits behind the small [`Radio`] trait, implemented over +//! `bluer`/BlueZ in [`BluerRadio`]. Everything here that does not touch the +//! radio — the from/`Addr` mismatch-drop rule and the central-peripheral role +//! decision — is pure and unit-testable without hardware. + +use std::boxed::Box; + +use tines_core::consts::MAX_FRAME; +use tines_core::observation::NodeId; +use tines_core::transport::{Event, FrameBuf, SendError, Transport}; + +/// The radio I/O seam for [`BtTransport`]. +/// +/// A real implementation ( `transport::BluerRadio`, over `bluer`/BlueZ) drives +/// the Bluetooth stack on a background task and surfaces its events here. Kept +/// as a narrow trait so the pure transport logic can be tested with a mock and +/// never needs a real radio. +pub trait Radio { + /// The next raw radio event, or `None` if nothing is pending right now. + fn poll(&mut self) -> Option; + + /// Deliver `data` to `to`, tagging the outbound frame with `me` (this + /// device's advertised [`NodeId`]). + fn send(&mut self, me: NodeId, to: &NodeId, data: &[u8]) -> Result<(), RadioError>; + + /// The current usable MTU in bytes (L2CAP Connection-Capable Channel when + /// available, else the negotiated ATT MTU on the GATT fallback). + fn mtu(&self) -> usize; +} + +/// A raw event surfaced by a [`Radio`]. +pub enum RadioEvent { + /// A frame arrived from `from` (the peer's advertised [`NodeId`]). + Data { + /// The claimed source of the frame: the peer's advertised [`NodeId`]. + from: NodeId, + /// The frame bytes carried by the event. + data: Vec, + }, + /// The peer disconnected; any prior [`Event::Lost`] address is now gone. + Disconnected, +} + +/// Why a [`Radio::send`] call can fail. +#[derive(Debug)] +pub enum RadioError { + /// The radio is busy with an in-flight operation and cannot accept another. + Busy, + /// The peer is not currently reachable (no connection). + Unreachable, +} + +/// A BLE [`Transport`] whose address is the peer's advertised [`NodeId`]. +/// +/// Construct via [`BtTransport::new`], passing the device's own advertised id +/// (`me`), the peer's advertised id for the current connection (`peer`), and a +/// [`Radio`] (typically a `bluer`-backed `transport::BluerRadio`). +pub struct BtTransport { + /// This device's advertised [`NodeId`]; tags every outbound frame. + me: NodeId, + /// The peer's advertised [`NodeId`] for the current connection. Used to drop + /// spoofed/mismatched inbound frames; the peer's BLE address need not match. + peer: NodeId, + radio: Box, +} + +impl BtTransport { + /// Create a transport for a connection between `me` and `peer`. + pub fn new(me: NodeId, peer: NodeId, radio: impl Radio + 'static) -> Self { + BtTransport { + me, + peer, + radio: Box::new(radio), + } + } + + /// Whether this node should act as the BLE central/initiator for the + /// connection to `peer`. The numerically smaller [`NodeId`] initiates + /// (see [`is_central`]). + pub fn is_central(&self) -> bool { + is_central(self.me, self.peer) + } +} + +/// The robot with the numerically smaller [`NodeId`] acts as the BLE +/// central/initiator. +/// +/// NodeIds are distinct between peers, so this is a strict total order with no +/// tie; the `me == peer` case (which cannot occur for two distinct devices) +/// yields `false` — neither side initiates. +pub fn is_central(me: NodeId, peer: NodeId) -> bool { + me < peer +} + +impl Transport for BtTransport { + /// The peer's advertised [`NodeId`]; identity survives BLE address rotation. + type Addr = NodeId; + + /// Poll for the next inbound event. + /// + /// Inbound [`RadioEvent::Data`] frames whose `from` differs from the + /// connection's advertised [`NodeId`] are dropped here — a protocol + /// violation (address spoofing) at the transport, so core never observes a + /// mismatch. Frames too large for a [`FrameBuf`] are skipped rather than + /// aborting the poll. + fn poll(&mut self) -> Option> { + loop { + match self.radio.poll() { + Some(RadioEvent::Data { from, data }) => { + if from != self.peer { + continue; + } + match FrameBuf::from_slice(&data) { + Ok(buf) => return Some(Event::Frame(from, buf)), + Err(_) => continue, + } + } + Some(RadioEvent::Disconnected) => { + let peer = self.peer; + self.peer = [0u8; 16]; + return Some(Event::Lost(peer)); + } + None => return None, + } + } + } + + /// Attempt to deliver `frame` to `to`. + fn send(&mut self, to: &Self::Addr, frame: &[u8]) -> Result<(), SendError> { + if frame.len() > self.mtu() { + return Err(SendError::TooLarge); + } + self.radio.send(self.me, to, frame).map_err(|e| match e { + RadioError::Busy => SendError::Busy, + RadioError::Unreachable => SendError::Unreachable, + }) + } + + /// Maximum frame size in bytes this transport can send. + /// + /// Reports the radio's real MTU (L2CAP CoC when available, else the ATT + /// MTU on the GATT fallback), clamped to `MAX_FRAME` per the + /// [`Transport`] contract. Core folds summaries and fragments down to this. + fn mtu(&self) -> usize { + self.radio.mtu().min(MAX_FRAME) + } +} + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use std::thread; + +use futures::StreamExt; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::mpsc::{self as async_mpsc, UnboundedReceiver, UnboundedSender}; + +use bluer::{ + adv, + l2cap::{SocketAddr, Stream as L2capStream}, + monitor::{self, Monitor, MonitorEvent, Pattern, RssiSamplingPeriod}, + AddressType, Uuid, UuidExt, +}; + +use crate::adv::SERVICE_UUID; + +/// Fixed L2CAP PSM for the tines byte pipe. Shared with the peer (agreed +/// out-of-band): the L2CAP stream connects on this PSM on both sides, so the +/// numerically smaller [`NodeId`] (the central) is the one that lands the +/// connection first. +const TINES_L2CAP_PSM: u16 = 0x008D; + +/// A [`bluer`]/BlueZ-backed [`Radio`]. +/// +/// Runs the async `bluer` D-Bus stack on a detached background thread: it +/// powers the adapter, advertises this device's [`NodeId`] in service data, +/// monitors for a peer advertising the tines service UUID, and on discovery +/// opens an L2CAP byte pipe on `TINES_L2CAP_PSM`. Inbound bytes are surfaced +/// as [`RadioEvent::Data`] tagged with the peer's [`NodeId`] (which the +/// transport already knows — the BLE address is only how we route to it, and it +/// may rotate); outbound bytes are written to the pipe. The usable MTU is read +/// from the L2CAP socket and reported through [`Radio::mtu`]. +/// +/// The D-Bus session lives for the lifetime of this radio; [`BluerRadio`] +/// detaches (rather than joins) its background thread on drop, because the run +/// loop blocks on BLE activity for as long as the transport is alive. +/// +/// This backend establishes the L2CAP pipe from the initiating side (the smaller +/// [`NodeId`], per [`is_central`]); callers construct the transport only for the +/// peer they intend to talk to. +pub struct BluerRadio { + event_rx: UnboundedReceiver, + send_tx: UnboundedSender>, + mtu: std::sync::Arc, + handle: Option>, +} + +impl BluerRadio { + /// Start the `bluer`/BlueZ radio for a connection between `me` (this device) + /// and `peer` (the peer's advertised [`NodeId`]). + pub fn new(me: NodeId, peer: NodeId) -> Self { + let (event_tx, event_rx) = async_mpsc::unbounded_channel::(); + let (send_tx, send_rx) = async_mpsc::unbounded_channel::>(); + // Default to the transport's frame limit until the L2CAP socket reports a + // negotiated MTU; [`BtTransport::mtu`] clamps to MAX_FRAME regardless. + let mtu = Arc::new(AtomicUsize::new(MAX_FRAME)); + let mtu_for_run = Arc::clone(&mtu); + + let handle = thread::Builder::new() + .name(format!("tines-bt-bluer-{}", node_hex(peer))) + .spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build bluer runtime"); + let _ = rt.block_on(run(me, peer, event_tx, send_rx, mtu_for_run)); + }) + .expect("failed to spawn bluer radio thread"); + + BluerRadio { + event_rx, + send_tx, + mtu, + handle: Some(handle), + } + } +} + +impl Drop for BluerRadio { + fn drop(&mut self) { + // Detach the background radio thread: it owns the long-lived bluer D-Bus + // session and would block a join while waiting on BLE activity. + self.handle.take(); + } +} + +impl Radio for BluerRadio { + fn poll(&mut self) -> Option { + self.event_rx.try_recv().ok() + } + + fn send(&mut self, _me: NodeId, _to: &NodeId, data: &[u8]) -> Result<(), RadioError> { + self.send_tx + .send(data.to_vec()) + .map_err(|_| RadioError::Unreachable) + } + + fn mtu(&self) -> usize { + self.mtu.load(Ordering::Relaxed) + } +} + +/// Background async work: session, advertising, LE discovery, and the L2CAP +/// byte pipe. Returns once the connection drops. +async fn run( + me: NodeId, + peer: NodeId, + event_tx: UnboundedSender, + send_rx: UnboundedReceiver>, + mtu: Arc, +) -> Result<(), bluer::Error> { + let session = bluer::Session::new().await?; + let adapter = session.default_adapter().await?; + if let Err(e) = adapter.set_powered(true).await { + log::warn!("tines-bt: failed to power adapter: {e:?}"); + return Ok(()); + } + + // Advertise our own NodeId in service data so a peer can recognise us and + // learn our identity before connecting (address rotation aside). + let advertisement = adv::Advertisement { + advertisement_type: adv::Type::Peripheral, + service_uuids: BTreeSet::from([Uuid::from_u16(SERVICE_UUID)]), + service_data: BTreeMap::from([(Uuid::from_u16(SERVICE_UUID), me.to_vec())]), + discoverable: Some(true), + ..Default::default() + }; + if adapter.advertise(advertisement).await.is_err() { + log::warn!("tines-bt: failed to advertise; continuing with discovery"); + } + + // LE advertisement monitor: a peer advertising the tines service UUID. The + // monitor yields the peer's (rotating) BLE address; the peer's identity is + // the [`NodeId`] we already know (`peer`), which is what tags inbound frames. + let manager = adapter.monitor().await?; + let mut monitor = manager + .register(Monitor { + monitor_type: monitor::Type::OrPatterns, + rssi_sampling_period: Some(RssiSamplingPeriod::First), + patterns: Some(vec![Pattern { + data_type: 0x16, // Service Data — 16-bit UUID + start_position: 0, + content: SERVICE_UUID.to_le_bytes().to_vec(), + }]), + ..Default::default() + }) + .await?; + + while let Some(event) = monitor.next().await { + if let MonitorEvent::DeviceFound(found) = event { + let addr = found.device; + let sa = SocketAddr::new(addr, AddressType::LePublic, TINES_L2CAP_PSM); + match L2capStream::connect(sa).await { + Ok(stream) => { + if let Ok(mtu_bytes) = stream.as_ref().recv_mtu() { + let m = mtu_bytes as usize; + mtu.store(m.clamp(MIN_MTU, MAX_FRAME), Ordering::Relaxed); + } + let (read_half, write_half) = stream.into_split(); + let read_join = tokio::spawn(async move { + read_loop(peer, read_half, event_tx).await; + }); + let write_join = tokio::spawn(async move { + write_loop(write_half, send_rx).await; + }); + // The pipe is up. Block until either loop ends (disconnect), + // then tear down so the runtime can stop. + tokio::select! { + _ = read_join => {} + _ = write_join => {} + } + return Ok(()); + } + Err(e) => { + log::trace!("tines-bt: connect to {addr:?} failed: {e:?}"); + } + } + } + } + Ok(()) +} + +/// Read inbound bytes off the L2CAP pipe and emit them as [`RadioEvent::Data`], +/// tagged with the peer's [`NodeId`]. +async fn read_loop( + peer: NodeId, + mut read_half: P, + tx: UnboundedSender, +) { + let mut buf = vec![0u8; MAX_FRAME]; + loop { + match read_half.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + if tx + .send(RadioEvent::Data { + from: peer, + data: buf[..n].to_vec(), + }) + .is_err() + { + break; + } + } + Err(_) => break, + } + } +} + +/// Drain outbound bytes off the queue and write them to the L2CAP pipe. +async fn write_loop(mut write_half: P, mut rx: UnboundedReceiver>) { + while let Some(bytes) = rx.recv().await { + if write_half.write_all(&bytes).await.is_err() { + break; + } + } +} + +/// Smallest MTU we are willing to report (ATT's default minimum is 23). +const MIN_MTU: usize = 23; + +/// Render a [`NodeId`] as hex for thread naming (avoids pulling in a hex crate). +fn node_hex(node: NodeId) -> String { + use core::fmt::Write; + let mut s = String::with_capacity(node.len() * 2); + for b in node { + let _ = write!(s, "{b:02x}"); + } + s +} diff --git a/tines-bt/tests/adv.rs b/tines-bt/tests/adv.rs new file mode 100644 index 00000000..b1597126 --- /dev/null +++ b/tines-bt/tests/adv.rs @@ -0,0 +1,157 @@ +//! Integration tests for tines-bt advertising and the transport's mismatch-drop +//! rule. +//! +//! Every test uses a mock [`Radio`] and never touches a real BLE radio, so the +//! suite runs on a CI box with no Bluetooth stack. + +use std::collections::VecDeque; + +use tines_bt::{adv, BtTransport, Radio, RadioError, RadioEvent}; +use tines_core::observation::NodeId; +use tines_core::transport::{Event, Transport}; + +/// A canned [`Radio`] backed by queued events and a configurable MTU. +struct MockRadio { + events: VecDeque, + sent: Vec<(NodeId, Vec)>, + mtu: usize, +} + +impl MockRadio { + fn new(mtu: usize) -> Self { + MockRadio { + events: VecDeque::new(), + sent: Vec::new(), + mtu, + } + } + + fn push(&mut self, ev: RadioEvent) { + self.events.push_back(ev); + } +} + +impl Radio for MockRadio { + fn poll(&mut self) -> Option { + self.events.pop_front() + } + + fn send(&mut self, _me: NodeId, to: &NodeId, data: &[u8]) -> Result<(), RadioError> { + self.sent.push((*to, data.to_vec())); + Ok(()) + } + + fn mtu(&self) -> usize { + self.mtu + } +} + +#[test] +fn advertising_payload_fits_31_bytes() { + let p = adv::advertise([7u8; 16]); + assert_eq!(p.len(), adv::ADVERTISING_BUFFER_LEN); + + // First record is the flags AD: length 2, type 0x01, data 0x06. + assert_eq!((p[0], p[1], p[2]), (2, 0x01, 0x06)); + + // Second record is service data, 16-bit UUID: length 19, type 0x16. + assert_eq!((p[3], p[4]), (19, 0x16)); + + // Every meaningful byte lies within the legacy 31-byte advertising limit + // (the 16-byte NodeId ends at byte 23; the rest is zero padding). + let last = p.iter().rposition(|&b| b != 0).unwrap(); + assert_eq!(last, 22); +} + +#[test] +fn advertising_payload_roundtrips_node_id() { + let node: NodeId = [ + 0xde, 0xad, 0xbe, 0xef, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, + 0xbb, + ]; + let p = adv::advertise(node); + assert_eq!(adv::decode_node_id(&p), Some(node)); +} + +#[test] +fn from_mismatch_is_dropped() { + let me: NodeId = [1u8; 16]; + let peer: NodeId = [2u8; 16]; // the connection's advertised NodeId + let spoof: NodeId = [9u8; 16]; // claimed source that does not match `peer` + + // A single spoofed frame: the transport drops it and emits nothing. + let mut radio = MockRadio::new(23); + radio.push(RadioEvent::Data { + from: spoof, + data: vec![0xCA, 0xFE], + }); + let mut t = BtTransport::new(me, peer, radio); + assert_eq!( + t.poll(), + None, + "a frame from a non-advertised id yields no Event::Frame" + ); + + // A legitimate frame from `peer` is delivered unchanged. + let mut good = MockRadio::new(23); + good.push(RadioEvent::Data { + from: peer, + data: vec![0x01, 0x02], + }); + let mut t = BtTransport::new(me, peer, good); + match t.poll() { + Some(Event::Frame(from, data)) => { + assert_eq!(from, peer); + assert_eq!(&data[..], &[0x01, 0x02]); + } + other => panic!("expected a Frame from {peer:?}, got {other:?}"), + } + + // And a spoofed frame sitting before a legit one is simply skipped. + let mut mixed = MockRadio::new(23); + mixed.push(RadioEvent::Data { + from: spoof, + data: vec![0xFF], + }); + mixed.push(RadioEvent::Data { + from: peer, + data: vec![0x03], + }); + let mut t = BtTransport::new(me, peer, mixed); + match t.poll() { + Some(Event::Frame(from, data)) => { + assert_eq!(from, peer); + assert_eq!(&data[..], &[0x03]); + } + other => panic!("expected the Frame from {peer:?}, got {other:?}"), + } +} + +#[test] +fn smaller_node_id_is_central() { + let a: NodeId = [0u8; 16]; + let b: NodeId = [1u8; 16]; + assert!(tines_bt::is_central(a, b)); + assert!(!tines_bt::is_central(b, a)); +} + +#[test] +fn mtu_is_clamped_to_max_frame() { + // A radio that advertises an L2CAP-CoC-style MTU larger than MAX_FRAME must + // still report MAX_FRAME to honour the Transport contract. + let radio = MockRadio::new(4096); + let t = BtTransport::new([1u8; 16], [2u8; 16], radio); + assert_eq!(t.mtu(), tines_core::consts::MAX_FRAME); +} + +#[test] +fn send_enforces_mtu() { + let radio = MockRadio::new(23); + let mut t = BtTransport::new([1u8; 16], [2u8; 16], radio); + // A frame larger than the 23-byte MTU must be rejected as TooLarge. + let big = vec![0u8; 24]; + assert_eq!( + t.send(&[2u8; 16], &big), + Err(tines_core::transport::SendError::TooLarge) + ); +}