RosLibRust Guides
This book contains supplemental information and extended documentation for RosLibRust.
To get started using RosLibRust checkout the Quick Getting Started guide if you are comfortable with the basics of rust and ROS, otherwise checkout the Extended Getting Started guide for a more in depth guide that explains more of the concepts.
To learn about what RosLibRust is and why it was created checkout the Introduction.
Quick Getting Started
Brief guide for setting up roslibrust, if you get stuck or want a more in depth guide checkout Extended Getting Started
Add roslibrust as a dependency in Cargo.toml
:
[package]
name = "example_package"
version = "0.1.0"
edition = "2021"
# What crates your code needs when built regularly (e.g. `cargo build` or `cargo run`)
[dependencies]
# Here we specify what features from roslibrust we want our main code to have access to
# We'll need at least one backend, rosbridge is shown here, but any could be substituted in
# We also need the codegen feature as the types that are generated in build.rs rely on types from codegen
roslibrust = { path = "../roslibrust", features = ["rosbridge", "codegen"] }
# We need a tokio runtime to setup our async behaviors, these are the minimum tokio features we need
tokio = { version = "1", features = ["sync", "macros"] }
# Brining in a logger to help with debugging
env_logger = "0.11"
# What crates your code needs for testing and examples
[dev-dependencies]
# Tests use the mock backend from roslibrust
roslibrust = { path = "../roslibrust", features = ["mock"] }
# Tests also use test-util to pause time
tokio = { version = "1", features = ["test-util"] }
# What crates your code needs to run it's build.rs file
[build-dependencies]
# We need the codegen feature when running build.rs to be able to actually generate the types
roslibrust = { path = "../roslibrust", features = ["codegen"] }
Setup a build.rs
file to generate ROS types, and set your search_paths
to point at your ROS messages:
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Define our search paths
// Note: these currently point towards the assets folder in the roslibrust repository,
// you'll want to point this at the location of your own .msg/.srv files
let p = vec![
"../assets/ros2_common_interfaces".into(),
"../assets/ros2_required_msgs/rcl_interfaces/builtin_interfaces".into(),
];
// Actually invoke code generation on our search paths.
let (source, dependent_paths) =
roslibrust::codegen::find_and_generate_ros_messages_without_ros_package_path(p)?;
// This returns two things:
// 1) A TokenStream which is the rust code we want to generate
// 2) A list of paths that if modified would require the code to be regenerated. We use this to inform Cargo
// of when to re-run our build script.
// It is important for build scripts to only output files to OUT_DIR which is an environment variable set by Cargo.
let out_dir = std::env::var_os("OUT_DIR").unwrap();
// Name of the file in out_dir we want to write our generated code to
let dest_path = std::path::Path::new(&out_dir).join("messages.rs");
// Write the generated code to disk
std::fs::write(dest_path, source.to_string())?;
// If we stopped at this point, our code would still work, but Cargo would not know to rebuild
// our package when a message file changed.
// Cargo recognizes certain command line strings that build scripts print out:
for path in &dependent_paths {
// Tell cargo to re-run our build script if any of these files change
println!("cargo:rerun-if-changed={}", path.display());
}
Ok(())
}
Write a basic generic node with tests, and profit:
//! This file shows how to correctly import files generated by build.rs:
// This macro trick correctly "imports" messages.rs into our crate
// This should only be invoked once in the crate and other locations can access the
// messages via `use`
include!(concat!(env!("OUT_DIR"), "/messages.rs"));
// Important to bring traits we need into scope from roslibrust
// In this case we need to the Publish trait in scope so we can access the .publish() function on our Publisher
use roslibrust::Publish;
// Writing a simple behavior that uses the generic traits from roslibrust
// and the generated types from the macro above.
async fn pub_counter(ros: impl roslibrust::Ros) {
let publisher = ros
.advertise::<std_msgs::Int16>("example_counter")
.await
.unwrap();
let mut counter = 0;
loop {
publisher
.publish(&std_msgs::Int16 { data: counter })
.await
.unwrap();
println!("Published {counter}");
counter += 1;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
// Our actual "main" here doesn't do much, just shows the generate types
// are here and real.
#[tokio::main]
async fn main() {
// Create a rosbridge client we can use
let ros = roslibrust::rosbridge::ClientHandle::new("ws://localhost:9090")
.await
.unwrap();
// Start our behavior while waiting for ctrl_c
tokio::select! {
_ = pub_counter(ros) => {}
_ = tokio::signal::ctrl_c() => {}
}
}
// Setup a test of our pub_counter behavior
#[cfg(test)]
mod test {
use super::*;
use roslibrust::{Subscribe, TopicProvider};
#[tokio::test]
async fn test_pub_counter() {
// See: https://tokio.rs/tokio/topics/testing
// This test doesn't take 1 second to run even thou it looks like it should!
// Tokio simulates time in tests if you call pause()
// This test takes 0.00s to run on a reasonable machine
tokio::time::pause();
let ros = roslibrust::mock::MockRos::new();
// Subscribe to the topic we're publishing to
let mut subscriber = ros
.subscribe::<std_msgs::Int16>("example_counter")
.await
.unwrap();
// Start publishing in the background
tokio::spawn(async move { pub_counter(ros).await });
// Confirm we get the first message
let msg = subscriber.next().await.unwrap();
assert_eq!(msg.data, 0);
// Confirm second message quickly times out
let msg =
tokio::time::timeout(tokio::time::Duration::from_millis(10), subscriber.next()).await;
assert!(msg.is_err());
// Wait a bit
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
// Now get second message
let msg = subscriber.next().await.unwrap();
assert_eq!(msg.data, 1);
}
}
Getting Started
Inside the roslibrust repository you'll find example_package and example_package_macro which serve as good examples of how to integrate roslibrust into a package.
This documentation walks you through how those packages are setup, and how to get started using RosLibRust in your own project.
We recommend using the build.rs approach shown in example_package, as this approach will automatically re-generate your ROS types when the underlying .msg/.srv files are changed. The macro approach shown in example_package_macro is easier to setup, but can't detect when the underlying .msg/.srv files are changed. This approach is fine to use if you know your .msg/.srv files are not changing, or if you are ok with manually re-running the macro to generate the types.
This tutorial is written for someone who is new to both Rust and ROS, and assumes no prior knowledge of either.
Machine Setup
RosLibRust is currently only actively tested on Linux, however it should work on Windows and MacOS as well. If you run into any issues on Windows or MacOS please open an issue on the roslibrust github.
The only requirement to building roslibrust is the rust compiler and toolchain.
These are best installed via the instructions on the rust website.
If you are using Linux you can install the rust toolchain by running:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
RosLibRust currently requires a version of the Rust compiler greater than 1.85. This can be checked by running rustc --version
.
If you can run this command and see a version greater than 1.85 you're good to move on to the next step.
For actually running the example applications show here you'll need a working ROS installation with the rosbridge_server
package.
These instructions show using docker images to run isolated ROS environments.
To exactly follow these instructions you'll need docker engine
installed and running on your system.
However, docker can by bypassed if you prefer to directly install a specific version of ROS on your system.
To install docker engine see the docker website.
Making an Empty Project
For a longer explanation of this section see the Hello Cargo section of the Rust Book.
In a folder of your choice run cargo new my_package
. This will create a new rust package in the my_package
folder.
Cargo (rust's build tool) will automatically generate several files for you in this directory:
my_package
├── .git # A git repository is automatically created for you. You should use git!
├── .gitignore # A gitignore file is automatically created for you with good defaults for a Rust project.
├── Cargo.toml # Controls dependencies and other metadata for your package, equivalent to CMakeLists.txt in C++ or package.xml in ROS
└── src # All the "main" code for your package lives in this folder
└── main.rs # A starting file with some initial code in it
Now that we've created this project we can:
# Change into the directory of our new package.
# Once we've done this cargo commands will automatically detect the Cargo.toml file and know
# that they should operate on this package
cd my_package
# This command with both fully compile our package and all its dependencies, and then run the
# resulting executable
cargo run
If you see Hello, world!
printed to the console you're good to move on to the next step.
Setting Up Cargo.toml
To use roslibrust we need to modify Cargo.toml
to add roslibrust as a dependency.
If you're new to Rust you should checkout this chapter in the Cargo Book: Dependencies
An example Cargo.toml
is:
[package]
name = "my_package"
version = "0.1.0"
edition = "2021"
# What crates your code needs when built regularly (e.g. `cargo build` or `cargo run`)
[dependencies]
# You will need to specify at least one backend to use with roslibrust, available options are [ros1, rosbridge, zenoh, mock] (ros2 is coming soon)
# You will also need to specify the "codegen" feature, as the generated ROS types rely on features from this module
roslibrust = { version = "0.15", features = ["rosbridge", "codegen"] }
# RosLibRust is built on tokio, and requires a multi-threaded tokio runtime.
# You don't need the "full" tokio feature set, but it is a good starting place
tokio = { version = "1", features = ["full"] }
# What crates your code needs for testing and examples
[dev-dependencies]
# For testing you'll want to use the "mock" backend if you specify it here, it won't affect your production builds
roslibrust = { version = "0.15", features = ["mock"] }
# What crates your code needs to run it's build.rs file
[build-dependencies]
# In build.rs we'll use roslibrust's codegen features to generate Rust types from ROS .msg/.srv files
roslibrust = { version = "0.15", features = ["codegen"] }
Once we've modified Cargo.toml
in this way we can run cargo build
,
and we should see Cargo automatically download and compile all the dependencies we've specified.
If this works you're ready to move on to the next step.
Setting Up Automatic Rust<->ROS Type Generation
In ROS based systems "nodes" communicate with each other by sending and receiving messages. Because ROS supports multiple languages (Python, C++, Rust, etc.) a common schema language was needed. ROS uses calls these custom message definitions "Interfaces" and documents them in the ROS documentation's Basic Concepts section.
To work ergonomically in Rust with these messages we want a corresponding Type in Rust for each message type. RosLibRust will automatically generate these types for us, and uses the generate types to serialize and deserialize messages as they are sent and received.
To setup automatic generation of these Rust types from ROS interface files, we'll leverage Rust's build.rs feature.
A build.rs
file is a special file that Cargo will automatically run before compiling your crate.
This file can be used to generate code, compile native dependencies, or perform any other task needed to build your crate.
Learn more about writing build.rs
files in the Cargo Book's build scripts section.
Let's create a copy of build.rs file from example_package in our package:
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Define our search paths
// Note: these currently point towards the assets folder in the roslibrust repository,
// you'll want to point this at the location of your own .msg/.srv files
let p = vec![
"../assets/ros2_common_interfaces".into(),
"../assets/ros2_required_msgs/rcl_interfaces/builtin_interfaces".into(),
];
// Actually invoke code generation on our search paths.
let (source, dependent_paths) =
roslibrust::codegen::find_and_generate_ros_messages_without_ros_package_path(p)?;
// This returns two things:
// 1) A TokenStream which is the rust code we want to generate
// 2) A list of paths that if modified would require the code to be regenerated. We use this to inform Cargo
// of when to re-run our build script.
// It is important for build scripts to only output files to OUT_DIR which is an environment variable set by Cargo.
let out_dir = std::env::var_os("OUT_DIR").unwrap();
// Name of the file in out_dir we want to write our generated code to
let dest_path = std::path::Path::new(&out_dir).join("messages.rs");
// Write the generated code to disk
std::fs::write(dest_path, source.to_string())?;
// If we stopped at this point, our code would still work, but Cargo would not know to rebuild
// our package when a message file changed.
// Cargo recognizes certain command line strings that build scripts print out:
for path in &dependent_paths {
// Tell cargo to re-run our build script if any of these files change
println!("cargo:rerun-if-changed={}", path.display());
}
Ok(())
}
Before this build.rs script can run successfully we'll need to give it some real ROS messages to find. For this example we'll use some standard ROS2 messages from the ROS2 Common Interfaces repository.
To clone these messages into our package we can run:
# Make sure we're in the root of our package
cd my_package
# Make a folder to hold our messages
mkdir assets
# Clone the common interfaces into that folder
git submodules add https://github.com/ros2/common_interfaces assets/common_interfaces
Warning: ROS messages can refer to other messages in their contents.
For example many messages include a Header
message from the std_msgs
package.
For code generation to work correctly you must include all the messages you want to generate, AND all the messages that those messages depend on.
In this specific case, the messages in common_interfaces
rely on messages from the builtin_interfaces
which is not included in
that same repository. To fix this we'll also need to clone the rcl_interfaces
repository which contains the builtin_interfaces
package:
# Make sure we're in the root of our package
cd my_package
# Clone the rcl_interfaces repository into our assets folder
git submodules add https://github.com/ros2/rcl_interfaces assets/rcl_interfaces
Now we just need to modify the search_paths
variable in our build.rs
file to point at our new messages:
let search_paths = vec![
"assets/common_interfaces".into(),
"assets/rcl_interfaces/builtin_interfaces".into(),
];
Now if we run cargo build
again we should see Cargo automatically run our build.rs
file, and generate our Rust types from the ROS messages.
This won't be immediately obvious from the command line, but we can go look in the target
folder of our package to see the generated code.
The generated code will be in target/debug/build/my_package-<some hash of our package>/out/messages.rs
.
We can check if the file exists by running:
find -name "messages.rs"
If this prints out a path to a messages.rs
file we're good to move on to the next step.
Using Generated Types
Now that we've generated our types we can use them in our code. Rust luckily has some convenient macros for bringing generated code "into scope" for a crate.
If we open up src/main.rs
and add the following line at the top of the file:
include!(concat!(env!("OUT_DIR"), "/messages.rs"));
It will automatically find the generated messages.rs
and effectively "copy paste" the contents of that file into our main.rs
file.
Breaking down how that line works:
env!("OUT_DIR")
is a macro that expands to the value of theOUT_DIR
environment variable. This is set by Cargo and points to the folder where our generated code is located.concat!(...)
is a macro that concatenates multiple string literals into a single string literal. In this case we're concatenating theOUT_DIR
environment variable with the path to our generated code.include!(...)
is a macro that includes the contents of the file at the specified path into the current file. In this case we're including the generatedmessages.rs
file into ourmain.rs
file.
Once we've added that line to main.rs
all our generated messages will be available to us in the rest of our code.
For this example we'll keep it simple and leave that line in main.rs
, but in larger projects it is recommended to move the generated types to either a msgs module or a separate msgs crate in a workspace.
Writing Our First Node
Your now ready to actually write some code that uses RosLibRust!
We're going to start with a basic example of publishing to a topic.
Modify src/main.rs
to look like the following:
// Bring generated messages into scope:
include!(concat!(env!("OUT_DIR"), "/messages.rs"));
// Bring in traits we need from roslibrust
use roslibrust::traits::{Publish, Ros, Subscribe};
use std::sync::Arc;
use tokio::sync::Mutex;
// Writing a simple publisher behavior using roslibrust's generic traits
async fn pub_counter(ros: impl Ros, state: Arc<Mutex<u32>>) {
// This will nicely control the rat our code runs at
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
// Create a publisher on our topic
let publisher = ros
.advertise::<std_msgs::UInt32>("/example_counter")
.await
.expect("Could not create publisher!");
loop {
// Wait for next tick of our interval timer
interval.tick().await;
// Lock our state and read the current value
let cur_val = *state.lock().await;
// Publish the current value
publisher
.publish(&std_msgs::UInt32 { data: cur_val })
.await
.expect("Failed to publish message!");
// Increment our state
*state.lock().await += 1;
}
}
// This macro sets up a basic tokio runtime for us and lets our main function be `async`
#[tokio::main]
async fn main() {
// Initialize a logger to help with debugging
env_logger::init();
// Create a rosbridge client we can use
let ros = roslibrust::rosbridge::ClientHandle::new("ws://localhost:9090")
.await
.expect("Failed to connect to rosbridge!");
// Create a shared state we can use to track our counter
let publisher_state = Arc::new(Mutex::new(0));
// Spawn a new tokio task to run our publisher:
tokio::spawn(pub_counter(ros, publisher_state));
// Wait for ctrl_c
tokio::signal::ctrl_c().await.unwrap();
}
There is a lot to break down in this example, it uses many of the features of Rust, Tokio, and RosLibRust.
Let's start with the high level structure:
- Our "node" is defined in an
async fn
this allows to spawn an instance of our node as a new tokio task. - Our node uses
impl Ros
for the type of theros
parameter. This makes the function generic over any roslibrust backend. - Our main function sets up the dependencies of our node, and then spawns it as a new tokio task to run independently.
- We use
Arc<Mutex<>>
to share mutable state between our tokio tasks.
Right now, why we did all these things might not be obvious, but it will be once we start wanting to write more complex nodes and when we want to test those nodes.
Running Our Node
So far we've avoided installing any version of ROS at all. This is great since we can write and run our node on a system without any ROS making our code extremely portable. However, to actually run our node we'll want a ROS system to connect to.
One way to setup this up would be to go through a full ROS installation for either ROS1
or ROS2
, but the recommended approach for roslibrust is use a ROS installation inside a docker container.
This unfortunately introduces the complexity of docker, but it is a very portable and repeatable way to setup a ROS environment.
Furthermore, it makes it extremely easy to experiment with multiple versions of ROS!
ROS provides docker images for both ROS1 and ROS2 on their dockerhub page.
RosLibRust is publishing extended docker images that include a rust installation and the rosbridge_server
package.
We use these images for developing RosLibRust, and to run our CI tests.
To startup a ROS2 kilted rosbridge server you can run the following commands:
# This will startup a docker container with everything installed in it and drop you into a bash shell inside of the container
docker run -it --network host carter12s/roslibrust-ci-kilted:latest bash
# This will now activate the ROS2 installation inside the container
source /opt/ros/kilted/setup.bash
# This will start up the ROS2 zenoh router, and leave it running in the background
ros2 run rmw_zenoh_cpp rmw_zenohd & disown
# This will start the rosbridge server on the default port of 9090
ros2 run rosbridge_server rosbridge_websocket
Now in a separate terminal we can run our node.
To be able to actually see what our node is doing we'll enable debug logging with the RUST_LOG
environment variable (more info on RUST_LOG):
RUST_LOG=debug cargo run
We should now see our terminal output something like:
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.15s
Running `/home/carter/roslibrust/target/debug/examples/getting_started_1`
[2025-09-21T19:39:54Z DEBUG roslibrust_rosbridge::client] Starting a stubborn_connect attempt to ws://localhost:9090
[2025-09-21T19:39:54Z DEBUG tungstenite::handshake::client] Client handshake done.
[2025-09-21T19:39:54Z DEBUG roslibrust_rosbridge::client] Starting stubborn_spin
[2025-09-21T19:39:54Z DEBUG roslibrust_rosbridge::client] Advertise got lock on comm
[2025-09-21T19:39:54Z DEBUG roslibrust_rosbridge::comm] Sending advertise: Text("{\"op\":\"advertise\",\"topic\":\"/example_counter\",\"type\":\"std_msgs/UInt32\"}")
[2025-09-21T19:39:54Z DEBUG roslibrust_rosbridge::client] Publish got write lock on comm
[2025-09-21T19:39:54Z DEBUG roslibrust_rosbridge::comm] Sending publish: Text("{\"msg\":{\"data\":0},\"op\":\"publish\",\"topic\":\"/example_counter\",\"type\":\"std_msgs/UInt32\"}")
[2025-09-21T19:39:55Z DEBUG roslibrust_rosbridge::client] Publish got write lock on comm
We can also confirm everything is working by looking at the output of the rosbridge server in our other terminal. We should see a "Client connected" message when we startup our node and a "Client disconnected" message when we ctrl+c out of our node.
[INFO] [1758483594.506878271] [rosbridge_websocket]: Client connected. 1 clients total.
[INFO] [1758483600.897845391] [rosbridge_websocket]: Client disconnected. 0 clients total.
Extending Our Node to Subscribe
Let's have our node now subscribe to its same topic, and "talk to itself".
Along side our pub_counter
let's add a sub_counter
function:
async fn sub_counter(ros: impl Ros, state: Arc<Mutex<u32>>) {
// Create a subscriber on our topic
let mut subscriber = ros
.subscribe::<std_msgs::UInt32>("/example_counter")
.await
.expect("Could not create subscriber!");
loop {
// Wait for next message
let msg = subscriber.next().await.expect("Failed to get message!");
// Print the message
println!("Got message: {}", msg.data);
// Decrement our state
*state.lock().await -= 1;
}
}
And then we'll modify our main
function to spawn both behaviors:
// This macro sets up a basic tokio runtime for us and lets our main function be `async`
#[tokio::main]
async fn main() {
// Initialize a logger to help with debugging
env_logger::init();
// Create a rosbridge client we can use
let ros = roslibrust::rosbridge::ClientHandle::new("ws://localhost:9090")
.await
.expect("Failed to connect to rosbridge!");
// Create a shared state we can use to track our counter
let shared_state = Arc::new(Mutex::new(0));
// Spawn a new tokio task to run our publisher:
tokio::spawn(pub_counter(ros.clone(), shared_state.clone()));
// Spawn a new tokio task to run our subscriber:
tokio::spawn(sub_counter(ros, shared_state.clone()));
// Wait for ctrl_c
tokio::signal::ctrl_c().await.unwrap();
}
Note: you can now see that we're calling .clone()
on our ros
and state
variables when we spawn our tasks.
For both of these variables, that creates an additional "handle" to the underlying data that can be owned by the new task.
Previously, we we're moving
ownership of these variables into our pub_counter
task, but now that we want to use them in multiple tasks we need to clone them.
When we run this example (with our docker image still up in the background) we'll see logging like:
[2025-09-21T19:49:30Z DEBUG roslibrust_rosbridge::client] Publish got write lock on comm
[2025-09-21T19:49:30Z DEBUG roslibrust_rosbridge::comm] Sending publish: Text("{\"msg\":{\"data\":0},\"op\":\"publish\",\"topic\":\"/example_counter\",\"type\":\"std_msgs/UInt32\"}")
[2025-09-21T19:49:30Z DEBUG roslibrust_rosbridge::client] Got message: Text("{\"op\": \"publish\", \"topic\": \"/example_counter\", \"msg\": {\"data\": 0}}")
[2025-09-21T19:49:30Z DEBUG roslibrust_rosbridge::client] got message: {"op": "publish", "topic": "/example_counter", "msg": {"data": 0}}
Got message: 0
Writing Tests for Our Node
Being able to unit and integration test our ROS code is a major feature of RosLibRust.
Let's now ensure our pub_counter
and sub_counter
behaviors work together in a test.
At the end of main.rs
add the following:
// cfg(test) here means that this code is only compile when invoking `cargo test` and doesn't get included in normal builds
#[cfg(test)]
mod test {
// Bring pub_counter and sub_counter into scope
use super::*;
// Tokio will automatically set up an individual async runtime for this test
#[tokio::test]
async fn test_pub_sub_counter() {
// To let us see how long the test takes to run record the current time
let tick = std::time::SystemTime::now();
// MAGIC HERE:
tokio::time::pause();
// Create a mock ros instance we can use
// This instance of ROS is unique to this test and won't interfere with any other tests running parallel
let ros = roslibrust::mock::MockRos::new();
// Creating separate states so we can inspect individually how they change
let publisher_state = Arc::new(Mutex::new(0));
let subscriber_state = Arc::new(Mutex::new(10));
// Spawn a new tokio task to run our publisher:
tokio::spawn(pub_counter(ros.clone(), publisher_state.clone()));
// Spawn a new tokio task to run our subscriber:
tokio::spawn(sub_counter(ros, subscriber_state.clone()));
// The publisher and subscriber run for a bit in the background
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let published_count = *publisher_state.lock().await;
let subscribed_count = *subscriber_state.lock().await;
// Check the exact number of messages our publisher and subscriber got
assert_eq!(
published_count, 10,
"Published count should be 10, but was {published_count}"
);
assert_eq!(
subscribed_count, 0,
"Subscribed count should be 0, but was {subscribed_count}"
);
// Purely for demonstration purposes, show how long this test takes to run
let tock = std::time::SystemTime::now();
println!("Test took in realtime {:?}", tock.duration_since(tick));
}
}
To understand this test you should first read Tokio's testing guide.
The key points are:
- Tokio's runtime can tell when all futures are block on "time pasing"
- When this happens it can determine which future will complete "soonest"
- It can then "fast forward" time to that point and poll all futures again
- This allows us to deterministically test our code that is driven by time "as fast as possible"
We can run this test with cargo test
and see the following:
running 1 test
test test::test_pub_counter ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
If we want to see what our test prints to the terminal we can run cargo test -- --no-capture
and see the following:
running 1 test
Got message: 0
Got message: 1
Got message: 2
Got message: 3
Got message: 4
Got message: 5
Got message: 6
Got message: 7
Got message: 8
Got message: 9
Test took in realtime Ok(167.189µs)
test test::test_pub_sub_counter ... ok
This test takes only 167 microseconds to run! This is because Tokio is able to deterministically fast forward time to the point where our futures will complete.
Conclusions
In this tutorial we've shown:
- How to setup a new crate to use roslibrust
- How to write generic ROS behaviors that can be tested in isolation and use any ROS backend
- How to run our node against a real ROS system using docker
- How to write a simple integration test that uses the mock ROS backend to test multiple behaviors together
After this you should have a good understanding of how to use RosLibRust to build and test ROS nodes in rust.
Introduction
RosLibRust is an alternative to the various existing ROS clients. RosLibRust may be a great fit for your project, or you may benefit from one of the other existing clients.
- ros2rust is ideal if you want to release ROS2 packages to the community, or want to add Rust to an existing ROS2 project. But requires a full ROS2 installation.
- rosrust is a solid ROS1 option, but doesn't support
async
and is un-maintained. - ros2_client is a pure rust client for ROS2 that supports
async
. However, it can only talk DDS and not Zenoh that ROS2 is migrating to.
RosLibRust is ideal for systems like facility control systems and cloud tools that need to interact with a variety of ROS systems, and don't want to "become ROS" themselves. RosLibRust has one API that supports a broad range of ROS versions and protocols.
Why was RosLibRust created?
RosLibRust was designed to help solve several challenges in the ROS ecosystem:
- The need for
async
clients. At the time RosLibRust was written there was noasync
for ROS1 at all. - The need for pure rust clients.
- RosLibRust nodes can be built on a system with only a rust compiler. Official rust docker images are ~500mb.
- Once compiled a RosLibRust node is a fully statically linked executable that can be run on any system. A RosLibRust ros1 publisher node is <10MB compiled and can be deployed in 5MB alpine docker image. Show me any other ROS node that I can deploy in <15 MB of size.
- The ROS1 -> ROS2 migration was painful. While many concepts in ROS remained very similar, the API and build changes were invasive. A API abstraction layer should have made this easier.
- ROS is pretty painful for both Unit and Integration testing.
How does RosLibRust solve these problems?
At a high level what RosLibRust provides is:
- An abstract API for ROS
- Implementations of the abstract API for ROS1, ROS2, and rosbridge
- A mock implementation of ROS for testing that allows deterministic "time traveling" tests
- Pure rust generation of types from ROS's .msg/.srv files that is ROS version agnostic
These combine to make RosLibRust a powerful tool for building ROS nodes in rust.
An Abstract API for ROS
At the heart of RosLibRust is a set of traits that define the API for interacting with ROS. These traits are defined in the roslibrust_common crate.
The most core trait is called Ros:
/// Represents all "standard" ROS functionality generically supported by roslibrust
///
/// Implementors of this trait behave like typical ROS node handles.
/// Cloning the handle does not create additional underlying connections, but instead simply returns another handle
/// to interact with the underlying node.
///
/// Implementors of this trait are expected to be "self de-registering", when the last node handle for a given
/// node is dropped, the underlying node is expected to be shut down and clean-up after itself
pub trait Ros: 'static + Send + Sync + TopicProvider + ServiceProvider + Clone {}
This trait was designed to mimic how "Node Handles" work. Breaking down this trait:
'static
- Something implementing this trait holds no references to non-static data. Making its lifetime very safe.Send
+Sync
- Something implementing this trait is safe to send and use between threads.Clone
- Something implementing this trait can be cloned, this allows us to make more handles and distribute them amongst our node.TopicProvider
- Is another trait in roslibrust that allows us to create publishers and subscribers.ServiceProvider
- Is another trait in roslibrust that allows us to create service clients and servers.
Taking a look at the TopicProvider trait:
/// This trait generically describes the capability of something to act as an async interface to a set of topics
///
/// This trait is largely based on ROS concepts, but could be extended to other protocols / concepts.
/// Fundamentally, it assumes that topics are uniquely identified by a string name (likely an ASCII assumption is buried in here...).
/// It assumes topics only carry one data type, but is not expected to enforce that.
/// It assumes that all actions can fail due to a variety of causes, and by network interruption specifically.
pub trait TopicProvider {
// These associated types makeup the other half of the API
// They are expected to be "self-deregistering", where dropping them results in unadvertise or unsubscribe operations as appropriate
// We require Publisher and Subscriber types to be Send + 'static so they can be sent into different tokio tasks once created
type Publisher<T: RosMessageType>: Publish<T> + Send + 'static;
type Subscriber<T: RosMessageType>: Subscribe<T> + Send + 'static;
/// Advertises a topic to be published to and returns a type specific publisher to use.
///
/// The returned publisher is expected to be "self de-registering", where dropping the publisher results in the appropriate unadvertise operation.
fn advertise<T: RosMessageType>(
&self,
topic: &str,
) -> impl Future<Output = Result<Self::Publisher<T>>> + Send;
/// Subscribes to a topic and returns a type specific subscriber to use.
///
/// The returned subscriber is expected to be "self de-registering", where dropping the subscriber results in the appropriate unsubscribe operation.
fn subscribe<T: RosMessageType>(
&self,
topic: &str,
) -> impl Future<Output = Result<Self::Subscriber<T>>> + Send;
}
We see a fairly advanced trait by Rust standards, with the following key features:
- We leverage Rust's Generic Associated Types (GATs) to define the Publisher and Subscriber types returned by the trait functions. This means that the type (including the size) that a given implementation returns is completely different from other implementations. The only requirement is the type implements the Publish and Subscribe traits.
- While at first glance they might not look like it. The
advertise
andsubscribe
functions are actuallyasync fn
and return Futures. This means we don't have to block our application on waiting for a connection to be established. - The advertise and subscribe functions are further generic over the message type. This is familiar to ROS users, but further compounds the complexity of the trait.
To finish the chain let's look at one more trait, Publish:
/// Indicates that something is a publisher and has our expected publish
/// Implementors of this trait are expected to auto-cleanup the publisher when dropped
pub trait Publish<T: RosMessageType> {
// Note: this is really just syntactic de-sugared `async fn`
// However see: https://blog.rust-lang.org/2023/12/21/async-fn-rpit-in-traits.html
// This generates a warning is rust as of writing due to ambiguity around the "Send-ness" of the return type
// We only plan to work with multi-threaded work stealing executors (e.g. tokio) so we're manually specifying Send
fn publish(&self, data: &T) -> impl Future<Output = Result<()>> + Send;
}
Publish finally provides the publish
function that we use to send messages on a topic, which is again async
and generic over the message type.
The combination of these traits is fairly complicated, but at the end of the day enables us to write code that is extremely agnostic to the underlying ROS implementation.
Furthermore, there is NO PERFORMANCE PENALTY for this abstraction.
Due to how monomorphic generics work in rust the compiler is able to completely erase the generic types at compile time, and optimize the code as if we had written it directly for the specific type.
What's the catch? Slightly worse compile times, but you only pay for what you use.
If you create a generic node that uses the Ros
trait, and use it with multiple backend implementations,
there will be separate compilations of the node for each backend.
Backend Implementations
Okay so RosLibRust has this abstract API? How can we actually use it?
RosLibRust provides several implementations of the Ros
trait:
- roslibrust_ros1 - Implements the TCPROS protocol that was the backbone of ROS1
- roslibrust_zenoh - Implements a variant of ROS1 communication used by zenoh-ros1-bridge
- roslibrust_rosbridge - Implements the rosbridge_suite websocket protocol
- COMING SOON roslibrust_ros2 - Implements the Zenoh communication used by rmw_zenoh in ROS2 from Kilted onwards
- roslibrust_mock - Implements a mock ROS perfect for testing
Typically we don't depend on these crates directly, but instead use them by enabling their corresponding features on roslibrust
.
[dependencies]
roslibrust = { version = "0.15", features = ["ros1"] }
The full list of features is:
ros1
- Enables the roslibrust_ros1 backendzenoh
- Enables the roslibrust_zenoh backendrosbridge
- Enables the roslibrust_rosbridge backendmock
- Enables the roslibrust_mock backendros2
- COMING SOON Enables the roslibrust_ros2 backendcodegen
- Provides access to the roslibrust_codegen crate for generating ROS message types in build.rs.macro
- Provides access to the roslibrust_codegen_macro crate for generating ROS message types using a proc-macro.all
- Enables all of the above features.
Pure Rust Type Generation
RosLibRust has implemented a full parser and code-generator for ROS message types. This allows us to generate Rust types from ROS .msg/.srv files at compile time. The generated types are fully compatible with all RosLibRust backends. Meaning a ROS1 .msg file can be used with ROS2 and vice versa.
The rosbridge backend support "generic" types with parsing fallbacks, see generic message example
A build.rs
file can be used to automatically generate Rust types at build time from ROS .msg/.srv files.
See example_package for a full example.
A proc-macro is also provided for generating types at compile time, see example_package_macro for an example.
This breaks tooling free from needing any ROS installation.
Mock Implementation for Testing
A major challenge for incorporating ROS into larger systems is testing. Traditionally testing ROS nodes involves either:
- Breaking all the "ROS Logic" and other logic apart and indpendently testing them.
- Launching a full ROS system in a testing environment and interacting with it.
The first approach leads to "extra abstraction" and often causes timing and messaging related bugs to be missed. The second approach is brittle, slow, and often a bottle neck for test times.
RosLibRust provides a mock implementation of ROS for testing that allows deterministic "time traveling" tests. See extended getting started guide for an example.