Unros
by Najman Husaini working under the Utah Student Robotics Club
Feel free to reach out to me at najman.husaini@utah.edu.
Foreword
Unros was created after I faced several issues with rclpy
and eventually reached my breaking point. In one week I had drafted this framework, and I have been developing it in my ideal image ever since. I am simply a student who learned Rust on their own; There isn't even a class at my university for it. As such, developing this framework also served as an exercise in my understanding of this language as much as it has served the club as a formidable competitor to the often used ROS framework from years prior.
I agree that people who are familiar with ROS should stick with ROS instead of burdening themselves with learning this framework. However, if you are new to robotics and Rust, this framework should not be a bad choice. On top of this, the robotics club is also actively using this framework and correcting it through active practice, so there will be continued maintenance and development for several years.
Finally, one could argue that a monopoly on robotics software, despite being open source, is still not a good thing and can get in the way of finding key innovations in the way we do robotics, so this framework serves that purpose as well. While we do intend to follow ROS in the places that it does well, we certainly are open in introducing breaking changes at this stage to find the best ways to program robots.
Introduction
Unros is a fully Rust framework targeted towards robotics applications. It attempts to follow in the footsteps of the Robotic Operating System 1 & 2 while leveraging Rust's unique features.
This framework aims to be memory safe, promote correctness, opinionated, and require minimal upfront configuration and setup. Developed in tandem with the framework is an entire ecosystem that provide various functionality like networking, serial connection, kinematics, autonomy, etc. In this regard, we follow Rust's ideology of delegating extra functionality to separate crates.
Features
There are several important things define Unros and its purpose:
- Nodes - An analogue to ROS Nodes. Since Rust already has fearless concurrency, Nodes simply encourage developers to write portable code with distinct functionality.
- Async Runtime - Unros adopts an async first runtime powered by
tokio
. Even if your code is not asynchronous, async runtimes are easier to manage and gracefully shutdown. - PubSub - A direct analogue to ROS publisher and subscribers. These are akin to mpmc channels except that each value is cloned for every consumer. Their API also attempts to mirror Rust's iterators for added flexibility.
- Logging - Unros has a complete logging solution in the form of text and video. Indeed, Unros can use existing
ffmpeg
installations (or automatically install one) to record and play videos on the spot from any source of images. - Integration - Unros uses common crates to better integrate with the Rust ecosystem as a whole. These include
nalgebra
,image
,log
, andserde
. Re-inventing the wheel is not a goal!
The Book
This book intends to provide a guided, hands-on tour of Unros and several useful crates of the ecosystem. By the end of this book, you should be able to understand the philosophy of this crate enough to write your own libraries and applications using this framework. You are expected to have an intermediate understanding of Rust. Understanding up to Chapter 10 of the Rust Book is good, but getting up to Chapter 17 is much better.
Getting Started
Unros is a Rust only framework with no required external dependencies1. As such, you only need to install Rust.
Unros requires the use of nightly features. The easiest way to do this is to create a rust-toolchain.toml
with the following contents:
[toolchain]
channel = "nightly"
It also requires the nightly channel for tokio
, so you should create a folder in your project called .cargo
with a file inside called config.toml
with the following contents:
[build]
rustflags = ["--cfg", "tokio_unstable"]
While ffmpeg
and ffplay
are used, they are sidecars and are thus not needed until any video encoding or playback is required. When code that requires these is invoked, an error will be returned with Result
instead of panics, so you will be able to catch those if needed. When possible, ffmpeg
will be automatically installed if such code is invoked.
Hello Goodbye
Hello World is a classic first project, but we also want to showcase how the runtime exits. To start, simply create a new project with any name you'd like:
cargo new hello-goodbye
Once you've done that, simply add the following to the dependencies in Cargo.toml
:
unros = "0.1"
Now, we shall start coding exclusively in main.rs
. First, let us modify the main
method. Here is what a classic one would look like:
fn main() {
println!("Hello world!");
}
The entire execution of the program is encapsulated within this method. In robotics, we typically have several systems that work concurrently and persistently. There is a lot of boilerplate involved in running these correctly, so we've handled that for you! Simply use the procedural macro, much in the same way as tokio::main
:
use unros::{Application, anyhow};
#[unros::main]
async fn main(app: Application) -> anyhow::Result<Application> {
println!("Hello world!");
Ok(app)
}
We've added quite a few things:
#[unros::main]
Invokes a procedural macro to add the boilerplate code.
async fn main(app: Application) -> anyhow::Result<Application>
main
can now run asynchronous code. The async runtime is generated automatically. The method now also accepts an Application
object. Nodes and tasks are added to this Application
which queues them up for execution. This Application
object is then returned assuming no errors occurred. If an error did occur, you can safely return it for it to be displayed according to anyhow
's formatting.
This Application
object is key, as it means that the application itself does not run until the main
method exits. Now, the entire execution of the program is no longer encapsulated in main
, so what is main
's purpose? Initialization. We always want errors to appear as early as possible, so perform all of your extensive error checking here before returning a correctly made Application
object, and let Unros handle the rest.
So, what does the Application
do right now? Well, nothing! It has not been modified at all, so Unros will not do anything. Try running the application right now.
cargo run
[0:0.00 unros_core] Runtime started with pid: 220566
Hello World!
[0:0.00 unros_core] All nodes have terminated
[0:0.00 unros_core] Exiting...
The program seems to be working just fine! You should also have noticed that a logs
folder was generated, and there is a .log
inside with the following contents:
[0:0.00 INFO unros_core] Runtime started with pid: 220566
[0:0.00 DEBUG auxilliary-control] Successfully binded to: 0.0.0.0:44537
[0:0.00 INFO unros_core] All nodes have terminated
[0:0.00 INFO unros_core] Exiting...
Note that Hello World!
is not present. This is because the log file only stores actual logs and not stdout
. This may be subject to change. Ignore the auxilliary-control
line for now. Your pid
will also be different. You can use this to kill the process if all else fails (which hopefully does not happen).
Goodbye-ing
Now, how do we actually do the goodbye part since when main
exits, there is still additional code that runs. How about implementing Drop
?
use unros::{Application, anyhow};
struct Goodbyer;
impl Drop for Goodbyer {
fn drop(&mut self) {
println!("Goodbye World!");
}
}
#[unros::main]
async fn main(app: Application) -> anyhow::Result<Application> {
println!("Hello world!");
let _goodbye = Goodbyer;
Ok(app)
}
Now try running this.
[0:0.00 INFO unros_core] Runtime started with pid: 220566
Hello World!
Goodbye World!
[0:0.00 INFO unros_core] All nodes have terminated
[0:0.00 INFO unros_core] Exiting...
Alright, we've finished what we've started! However, Unros still has not done anything for us. Namely, it has not ran any Nodes yet. Let's turn Goodbyer
into a node!
use unros::{Application, anyhow, async_trait, Node, NodeIntrinsics, RuntimeContext};
#[derive(Default)]
struct Goodbyer {
intrinsics: NodeIntrinsics<Self>
}
impl Drop for Goodbyer {
fn drop(&mut self) {
println!("Goodbye World!");
}
}
#[async_trait]
impl Node for Goodbyer {
const DEFAULT_NAME: &'static str = "goodbye";
async fn run(self, _context: RuntimeContext) -> anyhow::Result<()> {
Ok(())
}
fn get_intrinsics(&mut self) -> &mut NodeIntrinsics<Self> {
&mut self.intrinsics
}
}
#[unros::main]
async fn main(mut app: Application) -> anyhow::Result<Application> {
println!("Hello world!");
app.add_node(Goodbyer::default());
Ok(app)
}
Now this is a big jump, so lets go over it step by step.
NodeIntrinsics<Self>
This is an object that helps unros
track the state of a node as the app runs. It even tells you if you've made a node but have not added it to the app yet! Try commenting out the line with add_node
and run.
#[async_trait]
Node
is actually a trait with async functions, so until Rust adds that functionality natively with all the appropriate thread safety stuff, we are stuck with this approach.
const DEFAULT_NAME: &'static str = "goodbye";
For better logging, nodes should have names. However, having to provide a name every time you add a node is annoying, so nodes can have default names that are used when a name is not provided.
async fn run(self, _context: RuntimeContext) -> anyhow::Result<()>
This is the main body of the node. A node is only ran once, so everything that a node needs to do should be in this method. For now, we just return Ok
.
fn get_intrinsics(&mut self) -> &mut NodeIntrinsics<Self>
This method is called by unros
so don't worry about it. All you need to do is return a mutable reference to the field containing your intrinsics (you should only ever have 1).
If you run this, you will get the same output, so why don't we make the node wait for a few seconds before saying goodbye?
#[async_trait]
impl Node for Goodbyer {
const DEFAULT_NAME: &'static str = "goodbye";
async fn run(self, _context: RuntimeContext) -> anyhow::Result<()> {
unros::tokio::time::sleep(std::time::Duration::from_secs(3)).await;
Ok(())
}
fn get_intrinsics(&mut self) -> &mut NodeIntrinsics<Self> {
&mut self.intrinsics
}
}
Now, Goodbyer
will wait for three seconds before exiting. Since it is the only node, when it exits, the entire runtime also exits. Note that we are still printing Goodbye World!
, so the Drop
implementation is still being used. This is an important feature of Unros. All Nodes will be dropped, even if forcefully exiting by pressing Ctrl-C
twice!
You have officially written your first Node! Theoretically, you could publish this crate as a library, allowing people to add this Node to their applications just by calling add_node
. In ROS, Node's offered a trivial way to perform multithreading and error resistance. Rust has a much better system for concurrency, so Nodes themselves could be multithreaded. Instead, Nodes in Unros are a form of encapsulation that allow better code reuse and sharing, whilst also providing more useful and relevant logging. Always prefer to use Nodes over just spawning tasks and threads!