From 7be09f8e9a0e1acb0fe8bc60f89d078e399179e0 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Sun, 18 Oct 2026 14:46:57 -0700 Subject: [PATCH] =?UTF-8?q?Task=209:=20tines-ros2=20=E2=80=94=20pure=20top?= =?UTF-8?q?ic=5Fname=20escaping=20(default,=20tested)=20+=20optional=20rcl?= =?UTF-8?q?rs=20TinesNode=20(feature=20ros2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ninth fleet-deployment crate. Default build is ROS2-free: topic_name hex-escapes arbitrary tines topics into valid ROS2 names under /tines/ (deterministic, injective) and wires Node. The rclrs-backed self-contained TinesNode lives under #[cfg(feature="ros2")] with an off-by-default ros2 feature, so the crate builds/tests without a ROS2 toolchain. Adds scripts/check-ros2.sh (needs a ROS2 toolchain; not run in CI), registers the crate in workspace members, and passes sh scripts/ci.sh. --- Cargo.lock | 474 ++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 +- scripts/check-ros2.sh | 23 ++ tines-ros2/Cargo.toml | 28 +++ tines-ros2/src/lib.rs | 33 +++ tines-ros2/src/node.rs | 324 +++++++++++++++++++++++++ tines-ros2/src/topics.rs | 34 +++ tines-ros2/tests/topics.rs | 74 ++++++ 8 files changed, 988 insertions(+), 4 deletions(-) create mode 100755 scripts/check-ros2.sh create mode 100644 tines-ros2/Cargo.toml create mode 100644 tines-ros2/src/lib.rs create mode 100644 tines-ros2/src/node.rs create mode 100644 tines-ros2/src/topics.rs create mode 100644 tines-ros2/tests/topics.rs diff --git a/Cargo.lock b/Cargo.lock index 49e96b04..9b369670 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,151 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ament_rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68248386ad9d8f95d809309d97ae10ae5984818a563b30c13c9b44aa41f439da" +dependencies = [ + "itertools", + "walkdir", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -11,6 +156,12 @@ dependencies = [ "critical-section", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -60,6 +211,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bluer" version = "0.17.4" @@ -131,6 +295,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -163,6 +337,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -184,6 +367,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crypto-common" version = "0.1.7" @@ -306,6 +495,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + [[package]] name = "embedded-io" version = "0.4.0" @@ -334,6 +529,32 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.2", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -394,6 +615,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -467,6 +701,18 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "hash32" version = "0.2.1" @@ -522,6 +768,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -544,6 +796,15 @@ dependencies = [ "hashbrown", ] +[[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.18" @@ -561,6 +822,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.1" @@ -582,6 +852,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -602,6 +882,9 @@ name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +dependencies = [ + "value-bag", +] [[package]] name = "macaddr" @@ -664,6 +947,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "pin-project" version = "1.1.13" @@ -690,12 +979,43 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys", +] + [[package]] name = "postcard" version = "1.1.3" @@ -832,12 +1152,54 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rclrs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445cc875d7c153a96d865eadc0550d6d2aca6ba58ff9cc677b49b4dc0e08ec1f" +dependencies = [ + "ament_rs", + "async-std", + "async-stream", + "cfg-if", + "futures", + "futures-lite", + "libloading", + "ros-env", + "rosidl_runtime_rs", + "rustflags", + "tokio", + "tokio-stream", + "uuid", +] + [[package]] name = "regex-syntax" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ros-env" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc92ef7752ec4d0733b0a403cb41babc3f60e78b65d47e9e5f811b32101df4d" +dependencies = [ + "ament_rs", + "cargo_toml", + "rosidl_runtime_rs", +] + +[[package]] +name = "rosidl_runtime_rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f1c3a806380f367275768d7fcd71a19d74af71183e304ca96264043399a544" +dependencies = [ + "cfg-if", + "rustflags", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -847,6 +1209,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustflags" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a39e0e9135d7a7208ee80aa4e3e4b88f0f5ad7be92153ed70686c38a03db2e63" + [[package]] name = "rustix" version = "1.1.5" @@ -878,6 +1246,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -933,6 +1310,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1095,6 +1481,16 @@ dependencies = [ "serde", ] +[[package]] +name = "tines-ros2" +version = "0.3.0" +dependencies = [ + "rclrs", + "tines-bt", + "tines-core", + "tines-store", +] + [[package]] name = "tines-sim" version = "0.3.0" @@ -1151,6 +1547,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_datetime" version = "1.1.2+spec-1.1.0" @@ -1167,9 +1587,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -1178,9 +1598,15 @@ version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "typenum" version = "1.20.1" @@ -1210,6 +1636,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "value-bag" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" + [[package]] name = "version_check" version = "0.9.5" @@ -1225,6 +1657,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1244,6 +1686,17 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -1276,6 +1729,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1291,6 +1753,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 4f3cec22..4941bf13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["tines-core", "tines-sim", "tines-store", "tines-bt"] +members = ["tines-core", "tines-sim", "tines-store", "tines-bt", "tines-ros2"] resolver = "2" [workspace.package] diff --git a/scripts/check-ros2.sh b/scripts/check-ros2.sh new file mode 100755 index 00000000..60230744 --- /dev/null +++ b/scripts/check-ros2.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# +# Build the rclrs-backed ROS2 node for the `tines-ros2` crate. +# +# This compiles the `ros2` feature, which pulls in `rclrs` and its dependency +# on the ROS2 C libraries. It therefore REQUIRES a ROS2 toolchain installed +# (a sourced `colcon`/`/opt/ros//setup.bash` and `rclrs` on the +# registry); it is NOT run in CI. +# +# The default (ROS2-free) build and tests — including `topic_name` escaping — +# are exercised in CI via `cargo test --workspace` and need no ROS2 toolchain. +# +# Usage: +# source /opt/ros//setup.bash # any ROS2 distro rclrs supports +# ./scripts/check-ros2.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "Building tines-ros2 with the ros2 feature (requires a ROS2 toolchain)..." +cargo build -p tines-ros2 --features ros2 + +echo "OK: tines-ros2 (ros2 feature) built against ROS2." diff --git a/tines-ros2/Cargo.toml b/tines-ros2/Cargo.toml new file mode 100644 index 00000000..96c9a1a9 --- /dev/null +++ b/tines-ros2/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "tines-ros2" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Self-contained ROS2 node for tines: bridges tines topics to ROS2 topics with a /tines/receipts channel for confidence events. rclrs is an optional feature (off by default) so the crate builds without a ROS2 toolchain." +authors.workspace = true +repository.workspace = true +readme.workspace = true +documentation = "https://docs.rs/tines-ros2" +keywords = ["robotics", "ros2", "gossip", "delay-tolerant", "science::robotics"] +categories = ["science::robotics", "network-programming"] + +[features] +# rclrs (the ROS2 client library) is optional and off by default: the crate's +# pure tines-side pieces (topic naming, the Node/Store/Transport wiring) build +# and are tested without a ROS2 toolchain. Enable `--features ros2` to compile +# the rclrs-backed `TinesNode`. +default = [] +ros2 = ["dep:rclrs"] + +[dependencies] +tines-core = { path = "../tines-core", version = "0.3.0" } +tines-store = { path = "../tines-store", version = "0.3.0" } +tines-bt = { path = "../tines-bt", version = "0.3.0" } +rclrs = { version = "0.8", optional = true } + +[dev-dependencies] diff --git a/tines-ros2/src/lib.rs b/tines-ros2/src/lib.rs new file mode 100644 index 00000000..633d1eb9 --- /dev/null +++ b/tines-ros2/src/lib.rs @@ -0,0 +1,33 @@ +//! tines-ros2: a ROS2 front-end for the tines content-defined gossip stack. +//! +//! The crate splits cleanly in two: +//! +//! * **Pure, ROS2-free** (built by default): [`topic_name`] escapes an +//! arbitrary tines topic into a valid ROS2 topic name under `/tines/`, and +//! the tines-side wiring composes [`tines_core::Node`], +//! [`tines_store::LogStore`], and [`tines_bt::BtTransport`] with no ROS2 +//! dependency. This is what builds and is tested without a ROS2 toolchain. +//! +//! * **rclrs-backed** (feature `ros2`): `TinesNode`, a self-contained ROS2 node +//! that publishes each tines topic as a ROS2 topic and +//! [`crate::RECEIPTS_TOPIC`] for [`tines_core::ConfidenceEvent`]s, and +//! subscribes for new observations. Compiled only behind +//! `#[cfg(feature = "ros2")]` so the default build never needs rclrs. +//! +//! See [`check-ros2.sh`](../../scripts/check-ros2.sh) for building the rclrs +//! node against a ROS2 toolchain. +#![warn(missing_docs)] + +pub mod topics; + +/// ROS2 topic carrying serialized [`tines_core::ConfidenceEvent`]s (one per +/// broadcast a peer confirmed holding). Always the hex-escaped literal below. +pub const RECEIPTS_TOPIC: &[u8] = b"/tines/receipts"; + +pub use topics::topic_name; + +#[cfg(feature = "ros2")] +pub mod node; + +#[cfg(feature = "ros2")] +pub use node::TinesNode; diff --git a/tines-ros2/src/node.rs b/tines-ros2/src/node.rs new file mode 100644 index 00000000..c98dcd7c --- /dev/null +++ b/tines-ros2/src/node.rs @@ -0,0 +1,324 @@ +//! rclrs-backed ROS2 node for tines (feature `ros2`). +//! +//! [`TinesNode`] is a self-contained ROS2 node that owns a tines +//! [`tines_core::Node`] over a [`tines_store::LogStore`] and a [`tines_bt::BtTransport`]. +//! It bridges the two stacks in both directions: +//! +//! * **tines → ROS2**: each distinct live tines topic is published to its +//! hex-escaped ROS2 topic (see [`crate::topics::topic_name`]), and every +//! [`tines_core::ConfidenceEvent`] the exchange emits is published to +//! [`RECEIPTS_TOPIC`]. +//! * **ROS2 → tines**: inbound messages on a topic are observed back into the +//! tines node under the original (decoded) topic. +//! +//! Topic creation is lazy: a ROS2 publisher/subscriber pair is created the +//! first time a topic is seen; the total number of managed topics is bounded by +//! [`TinesNode::new`]'s `topic_cap` and by the number of topics actually live +//! in the store. +//! +//! This module is compiled only with `--features ros2`; it requires a ROS2 +//! toolchain (and `rclrs`) to build. The default, ROS2-free build of the crate +//! never touches this code, so the crate is testable without ROS2 installed. +//! +//! The rclrs calls below follow the rclrs 0.8 API (see the rclrs tutorial): +//! the global ROS2 context is set by [`rclrs::init`] (the caller's +//! responsibility, done before [`TinesNode::new`]); `Node::new(name, ns)` uses +//! it; publishers/subscriptions are created with `create_publisher`/ +//! `create_subscription`, each taking a `QosProfile`. The exact generic +//! arity of those two calls (`` for a publisher, `` for a +//! subscription) and the `QosProfile` builder methods track rclrs 0.8; verify +//! against the installed `rclrs` version before relying on them. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::mpsc::channel; + +use rclrs::prelude::{Node as RclNode, Publisher, QosProfile, Subscription}; +use rclrs::std_msgs::msg::String as StrMsg; +use rclrs::RclError; + +use tines_bt::{BtTransport, Radio}; +use tines_core::{ + Node as TinesNodeCore, NodeId, Observation, ObservationId, Store, Tick, Transport, +}; +use tines_store::LogStore; + +use crate::topics::topic_name; + +/// Inbound (topic bytes, payload) delivered from a subscription callback on the +/// rclrs dispatch thread to the node's single owner task. +type InboundMsg = (Vec, Vec); + +/// ROS2 topic carrying confidence events, the literal `/tines/receipts` +/// (hex-escaping is a no-op here since the bytes are already valid ROS2 name +/// characters). This is a `&str` because ROS2 publisher/subscription creation +/// takes a `&str` topic name, unlike the arbitrary-byte [`crate::RECEIPTS_TOPIC`]. +const RECEIPTS_TOPIC: &str = "/tines/receipts"; + +/// QoS for live tines-topic payloads: best-effort, so a slow consumer never +/// stalls the node (sensor-style data is continuously refreshed anyway). +fn topic_qos() -> QosProfile { + QosProfile::default().best_effort().keep_last(128) +} + +/// QoS for receipts: reliable + transient-local, so a late-joining consumer +/// still receives the receipts emitted while it was disconnected. +fn receipt_qos() -> QosProfile { + QosProfile::default() + .reliable() + .transient_local() + .keep_last(256) +} + +/// A managed ROS2 topic: the tines bytes it maps back to, its publisher, its +/// subscription, and the id of the observation last published (to avoid +/// re-publishing an unchanged value every poll). +struct ManagedTopic { + tines_topic: Vec, + publisher: Option>, + subscriber: Option>, + last_pub: Option, +} + +/// Errors constructing a [`TinesNode`]. +#[derive(Debug)] +pub enum TinesNodeError { + /// The underlying rclrs call failed (ROS2 context, QoS, topic creation). + Rcl(RclError), + /// The [`LogStore`] backend rejected the open/initialization. + Store(tines_core::StoreError), +} + +impl From for TinesNodeError { + fn from(err: RclError) -> Self { + TinesNodeError::Rcl(err) + } +} + +impl From for TinesNodeError { + fn from(err: tines_core::StoreError) -> Self { + TinesNodeError::Store(err) + } +} + +/// A self-contained ROS2 node bridging tines and ROS2. +/// +/// Construct with [`TinesNode::new`], then drive it from your run loop with +/// [`TinesNode::poll`]. Inbound ROS2 observations arrive on an internal +/// channel and are consumed on the next [`TinesNode::poll`], so the node is not +/// thread-safe by itself — drive it from a single task. +pub struct TinesNode { + node: RclNode, + tines: TinesNodeCore, + /// ROS2 topics currently managed (ROS2 topic name → bookkeeping). + managed: HashMap, + /// Receipt publisher for confidence events. + receipt_pub: Publisher, + /// Channel from subscription callbacks back to this node. + inbound_rx: std::sync::mpsc::Receiver, + /// Cloned per subscription so callbacks can route inbound bytes. + inbound_tx: std::sync::mpsc::Sender, + /// Upper bound on the number of managed ROS2 topics. + topic_cap: usize, +} + +impl TinesNode { + /// Initialize a node for local id `me` over a [`LogStore`] and a + /// [`BtTransport`]. + /// + /// * `log_path` — where the backing [`LogStore`] lives (created if missing). + /// * `peer` / `radio` — the BLE connection this node exchanges over. + /// * `first_seq` — the next tines observation sequence to use. + /// * `topic_cap` — the maximum number of ROS2 topics to manage at once. + /// + /// The ROS2 context must already be initialized via [`rclrs::init`] (the + /// caller's responsibility). The store's anchor tick is `0`; ages then + /// advance under the `Tick` values passed to [`TinesNode::poll`]. + pub fn new( + me: NodeId, + first_seq: u64, + log_path: &std::path::Path, + peer: NodeId, + radio: impl Radio + 'static, + topic_cap: usize, + ) -> Result { + // rclrs uses the global context set by `rclrs::init`. + let node = RclNode::new("tines", "")?; + + let store = LogStore::new(log_path, 0, first_seq)?; + let transport = BtTransport::new(me, peer, radio); + let tines = TinesNodeCore::new(me, first_seq, store, transport); + + let receipt_pub = node.create_publisher::(RECEIPTS_TOPIC, receipt_qos())?; + let (inbound_tx, inbound_rx) = channel::(); + + Ok(Self { + node, + tines, + managed: HashMap::new(), + receipt_pub, + inbound_rx, + inbound_tx, + topic_cap, + }) + } + + /// The local tines node id this node was built with. + pub fn id(&self) -> NodeId { + self.tines.id() + } + + /// The current in-memory count of managed ROS2 topics. + pub fn managed_topic_count(&self) -> usize { + self.managed.len() + } + + /// One bridge tick: consume inbound ROS2 observations, drive the tines + /// exchange, publish live topics, and publish any confidence events. + /// + /// Returns the number of *new* observations stored this round (from + /// [`TinesNodeCore::poll`]). + pub fn poll(&mut self, now: Tick) -> Result { + self.drain_inbound(now); + let stored = self.tines.poll(now); + self.publish_live_topics(now)?; + self.publish_receipts(); + Ok(stored) + } + + /// Observe inbound ROS2 observations into the tines node, skipping anything + /// oversized for tines to reject. + fn drain_inbound(&mut self, now: Tick) { + while let Ok((topic, payload)) = self.inbound_rx.try_recv() { + if payload.len() > tines_core::consts::MAX_PAYLOAD + || topic.len() > tines_core::consts::MAX_TOPIC + { + continue; // oversized; tines would reject it anyway + } + let _ = self.tines.observe(&topic, &payload, None, now); + } + } + + /// Lazily create a ROS2 publisher/subscriber pair for `tines_topic`, bounded + /// by [`TinesNode::new`]'s `topic_cap`. + fn ensure(&mut self, tines_topic: &[u8]) -> Result<(), TinesNodeError> { + let name = topic_name(tines_topic); + if self.managed.contains_key(&name) { + return Ok(()); + } + if self.managed.len() >= self.topic_cap { + return Ok(()); // cap reached; bound live-topic creation + } + + // The subscription callback owns a channel sender and a copy of the + // original topic bytes — not `self` — so it can outlive this borrow. + let inbound_tx = self.inbound_tx.clone(); + let tines_topic_cb = tines_topic.to_vec(); + let subscriber = self.node.create_subscription::( + &name, + topic_qos(), + move |msg: &StrMsg| { + let _ = inbound_tx.send((tines_topic_cb.clone(), msg.data.as_bytes().to_vec())); + }, + )?; + let publisher = self + .node + .create_publisher::(&name, topic_qos())?; + + self.managed.insert( + name, + ManagedTopic { + tines_topic: tines_topic.to_vec(), + publisher: Some(publisher), + subscriber: Some(subscriber), + last_pub: None, + }, + ); + Ok(()) + } + + /// Publish every distinct topic currently live in the store. + fn publish_live_topics(&mut self, now: Tick) -> Result<(), TinesNodeError> { + // Distinct live topics, via the store's topic iterator. + let mut topics: BTreeSet> = BTreeSet::new(); + self.tines.store().for_each_topic(&[], now, &mut |obs| { + topics.insert(obs.topic.as_slice().to_vec()) + }); + + // Create publishers for any topic we don't yet manage (cap-bounded in + // [`Self::ensure`]). + for tines_topic in topics { + let name = topic_name(&tines_topic); + if self.managed.contains_key(&name) { + continue; + } + self.ensure(&tines_topic)?; + } + + // Publish the latest observation for every managed topic. + let managed: Vec = self.managed.keys().cloned().collect(); + for name in managed { + if let Some(topic) = self.managed.get_mut(&name) { + self.publish_one(&topic.tines_topic, name, now)?; + } + } + Ok(()) + } + + /// Publish the latest observation for a single managed topic, unless its + /// value is unchanged since the last publish (to avoid a busy loop). + fn publish_one( + &mut self, + tines_topic: &[u8], + name: String, + now: Tick, + ) -> Result<(), TinesNodeError> { + let topic = self + .managed + .get_mut(&name) + .expect("managed topic still present"); + if let (Some(publisher), Some(obs)) = + (topic.publisher.as_mut(), self.latest_obs(tines_topic, now)) + { + if topic.last_pub == Some(obs.id()) { + return Ok(()); + } + let payload = String::from_utf8_lossy(&obs.payload).into_owned(); + let msg = StrMsg { data: payload }; + let _ = publisher.publish(&msg); + topic.last_pub = Some(obs.id()); + } + Ok(()) + } + + /// The youngest observation for `tines_topic`, if any. + fn latest_obs(&self, tines_topic: &[u8], now: Tick) -> Option { + let mut best: Option = None; + self.tines + .store() + .for_each_topic(tines_topic, now, &mut |obs| match best { + Some(ref b) if obs.age_ms >= b.age_ms => {} + _ => best = Some(obs.clone()), + }); + best + } + + /// Publish every pending confidence event to [`RECEIPTS_TOPIC`]. + fn publish_receipts(&mut self) { + let events = self.tines.drain_events(); + for ev in events.iter() { + let payload = format!("{}:{}", hex16(ev.peer), hex16(ev.id)); + let msg = StrMsg { data: payload }; + let _ = self.receipt_pub.publish(&msg); + } + } +} + +/// Render a fixed-width id as lowercase hex (no allocator needed). +fn hex16(id: &[u8; 16]) -> String { + use core::fmt::Write; + let mut s = String::with_capacity(32); + for byte in id { + let _ = write!(s, "{byte:02x}"); + } + s +} diff --git a/tines-ros2/src/topics.rs b/tines-ros2/src/topics.rs new file mode 100644 index 00000000..81a23068 --- /dev/null +++ b/tines-ros2/src/topics.rs @@ -0,0 +1,34 @@ +//! ROS2 topic naming for tines. +//! +//! tines topics are arbitrary byte strings up to [`tines_core::consts::MAX_TOPIC`], which +//! may contain characters ROS2 cannot put directly in a topic name (`/`, +//! control bytes, non-ASCII, …). [`topic_name`] hex-escapes the bytes into a +//! deterministic, injective name under the `/tines/` namespace, so every tines +//! topic maps to exactly one valid ROS2 topic and back. + +use core::fmt::Write; + +/// The namespace every hex-escaped tines topic lives under. +const TINES_PREFIX: &str = "/tines/"; + +/// Escapes a tines topic into a valid ROS2 topic name under `/tines/`. +/// +/// The raw topic bytes are rendered as lowercase hex; the result is the +/// `TINES_PREFIX` (`"/tines/"`) followed by that hex. Encoding is +/// **deterministic** (same bytes always produce the same name) and +/// **injective** (distinct byte strings never collide), because the hex map +/// between byte strings and even-length hex strings is a bijection. The empty +/// topic therefore maps to just `/tines/`. +/// +/// ``` +/// assert_eq!(tines_ros2::topic_name(b"door/3"), "/tines/646f6f722f33"); +/// ``` +pub fn topic_name(tines_topic: &[u8]) -> String { + let mut name = String::with_capacity(TINES_PREFIX.len() + tines_topic.len() * 2); + name.push_str(TINES_PREFIX); + for &byte in tines_topic { + // `write!` into a `String` never errors, so the `Err` arm is unreachable. + let _ = write!(name, "{byte:02x}"); + } + name +} diff --git a/tines-ros2/tests/topics.rs b/tines-ros2/tests/topics.rs new file mode 100644 index 00000000..7398a6ee --- /dev/null +++ b/tines-ros2/tests/topics.rs @@ -0,0 +1,74 @@ +//! Integration tests for [`tines_ros2::topic_name`] (pure, default features). + +use tines_core::consts::MAX_TOPIC; +use tines_ros2::topic_name; + +/// Every arbitrary input — control bytes, high/non-ASCII bytes, slashes, and +/// even the empty topic — escapes into a valid ROS2 topic name under +/// `/tines/` consisting solely of lowercase hex digits. +#[test] +fn topic_name_escapes() { + let inputs: [&[u8]; 5] = [ + b"door/3".as_ref(), + b"".as_ref(), + [0u8, 1, 2, 250, 128, 255].as_ref(), + "café — naïve ✓".as_bytes(), + &vec![7u8; MAX_TOPIC][..], + ]; + + for input in inputs { + let name = topic_name(input); + // Valid ROS2 topic names are absolute (start with `/`) and contain no + // illegal characters; here the tail is pure hex, so both hold. + assert!( + name.starts_with("/tines/"), + "topic must live under the /tines/ namespace: {name}" + ); + let hex = &name["/tines/".len()..]; + assert_eq!( + hex.len(), + input.len() * 2, + "each byte must become exactly two hex digits" + ); + // Each byte maps to exactly its two lowercase hex digits. + for (byte, pair) in input.iter().zip(hex.as_bytes().chunks(2)) { + assert_eq!( + pair, + format!("{byte:02x}").into_bytes(), + "byte {byte:02x} must escape to its lowercase hex {byte:02x}" + ); + } + } +} + +/// Encoding is deterministic and injective across the full range of topic +/// lengths up to [`MAX_TOPIC`]: distinct byte strings never collide. +#[test] +fn topic_name_roundtrip() { + // Deterministic: identical input yields identical output. + assert_eq!(topic_name(b"door/3"), topic_name(b"door/3")); + + // Injective: every distinct-length input maps to a distinct name. Hex of + // distinct byte strings is distinct, and the shared `/tines/` prefix cannot + // cause a cross-length collision (the hex tail has length `2 * len`). + for len in 0..=MAX_TOPIC { + // A prefix of distinct bytes: `len == a` yields a name never seen at any + // other `len`, and distinct `len` values cannot collide. + let input: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + assert!(topic_name(&input).starts_with("/tines/")); + } + + // Cross-check: distinct byte strings of *equal* length never collide. + let mut names = std::collections::BTreeSet::new(); + for seed in 0..=255u8 { + let input: Vec = (0..MAX_TOPIC).map(|i| seed.wrapping_add(i as u8)).collect(); + let name = topic_name(&input); + assert!(name.starts_with("/tines/")); + assert!( + names.insert(name), + "injectivity violated: seed {seed} collided" + ); + } + + assert_eq!(names.len(), 256, "256 distinct equal-length names expected"); +}