Node packages
This page is part of the Multi-Node Projects group. Previous: Project layout — Next: Bringup packages
A Node pkg is the unit of reusable behaviour in a multi-node workspace.
It is a Rust library — a [lib] crate — that implements one node and
registers it with nros::node!(T).
An image is what boots the binary; the Node pkg is what runs inside it.
Prereqs
Pick one path from a fresh checkout — just is NOT a prereq.
A. Front door (bare machine OK — no Rust, no just):
./scripts/bootstrap.sh
Installs rustup if needed and builds the in-tree nros CLI from
source at packages/cli/target/release/nros, leaving it on PATH for
this shell (in a checkout, the tree’s own build is the binary this tree
accepts — RFC-0090 / phase-431).
B. Already have cargo (equivalent — same build, same binary):
git submodule update --init packages/cli/third-party/play_launch
cargo build --release --manifest-path packages/cli/Cargo.toml --bin nros
export PATH="$PWD/packages/cli/target/release:$PATH"
Every subsequent shell sources the workspace env via one of:
direnv allow # if you use direnv
source ./activate.sh # bash / zsh
source ./activate.fish # fish
Then provision the native host:
nros setup linux --rmw zenoh
Scaffolding a Node pkg
Use nros new to create a skeleton:
nros new node talker_pkg
nros new node writes the package under src/ and adds its [[component]]
row to the bringup. There is no member list to maintain and no workspace root
to maintain it in: a workspace is a directory of packages (RFC-0098 D9), and
nros build discovers them under src/, generating the entry that names them
as path dependencies under build/.
(nros new <name> --platform linux --lang rust is the other verb, and a
different output — a standalone runnable project that states its own board
in its own system.toml. Use it for a copy-out example, not for a workspace
member.)
Node pkg anatomy
A Node pkg has three files:
src/talker_pkg/
├── package.xml # ROS 2 manifest — <exec_depend> per message package
├── Cargo.toml # [lib] + dependencies — Rust-toolchain facts only
└── src/lib.rs # impl Component + ExecutableNode; ends with nros::node!(Talker);
No fn main() here — a Node pkg is a library linked into the entry that
nros build generates for an image. That entry’s macro-generated runtime owns
nros::init, executor open, RMW registration, and the spin/yield loop.
Declaring the node — the [[component]] row
A Node pkg’s Cargo.toml is an ordinary Rust manifest: a [lib] target and
its dependencies, and nothing that describes a deployment. Who this node is
— which package provides it, which type implements it, what it is called — is
one [[component]] row in the Bringup pkg’s system.toml, beside the rows for
every other node in the system (RFC-0098 D5). From
examples/workspaces/rust/src/demo_bringup/system.toml:
[[component]]
pkg = "talker_pkg"
class = "talker_pkg::Talker"
name = "talker"
| Field | Purpose |
|---|---|
pkg | The package that provides the node (matches <name> in its package.xml) |
class | Fully-qualified path to the type that impls Node + ExecutableNode |
name | Default ROS 2 node name (remappable at launch) |
dispatch | Optional — the dispatch shape, when it is not the default |
The node package’s own manifest REGISTERS the class it exports, in a
[package.metadata.nros.node] table. That is not a deployment and not a second
copy of the row above: it is the Rust spelling of node registration, the
counterpart of a C/C++ package’s
nros_components_register_node(<target> PLUGIN pkg::Class …) in its
CMakeLists.txt (itself ROS’s rclcpp_components_register_node). The metadata
pipeline reads it — nros sync’s probe, nros check’s dispatch lint — and a
package registers once however many bringups launch it, whereas a [[component]]
row is one bringup’s choice to run it. It names no namespace: that is the launch
file’s to give, as it is for a C/C++ node
(issue 1289).
The manifest beside src/lib.rs is then just this:
[lib]
path = "src/lib.rs"
[package.metadata.nros.node]
class = "talker_pkg::Talker"
name = "talker"
[dependencies]
nros = { path = "../../../../../packages/api/nros", default-features = false,
features = ["alloc", "rmw-cffi"] }
A Node pkg names no platform and no RMW. alloc is the universal
baseline and rmw-cffi is the vtable seam; the concrete backend and the
platform both flow in from the board crate the generated entry depends on, via
cargo feature unification. That is what lets one talker_pkg deploy to a Linux
host and a Cortex-M board without a fork — the difference between them is an
[image.*] row, not a second copy of anything.
src/lib.rs — the node implementation
A Node pkg implements two traits: Component (declarative registration) and
ExecutableNode (per-callback body), then calls nros::node! to export the
trampolines the entry macro expects.
The node is the same type everywhere. ctx.create_node(...) returns an
nros::Node, the type a standalone program gets from
executor.create_node(name), with rclrs’s methods: create_publisher,
create_subscription(topic, callback), timers, name(), logger(), the graph
queries. A function written against &mut nros::Node serves both. What a
component adds is the DeclarativeNode extension (bring it into scope with
use nros::DeclarativeNode;): constructors that declare an entity by stable id
and bind its callback by NAME, which is what lets the board dispatch callbacks
from its own tasks and lets nros sync measure the component without running it.
Here is the essential shape, drawn from
examples/workspaces/rust/src/talker_pkg/src/lib.rs
(see that file for the full worked version):
#![allow(unused)]
fn main() {
use nros::{
CallbackCtx, ExecutableNode, Component, NodeContext, DeclarativeNode, NodeOptions, NodeResult,
TimerDuration,
};
pub struct Talker;
impl Component for Talker {
const NAME: &'static str = "talker";
// What this class's per-instance registries hold, in order: publishers,
// service servers, service clients, action clients, action servers.
const ENTITY_BOUNDS: nros::EntityBounds = nros::EntityBounds::exact(1, 0, 0, 0, 0);
fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
let mut node = ctx.create_node(NodeOptions::new("talker"))?;
let chatter = node.create_publisher_for_topic::<MyMsg>("/chatter")?;
let _timer =
node.create_timer_for_callback_name("on_tick", TimerDuration::from_millis(1000))?;
node.callback_for_name("on_tick")
.publishes_entity(&chatter)?;
Ok(())
}
}
impl ExecutableNode for Talker {
type State = i32;
fn init() -> Self::State { 0 }
// See the full example for the callback body.
}
nros::node!(Talker); // <-- exports the trampolines; this is the last line
}
Key points:
Component::registeris declarative — it runs once at startup to declare publishers, subscriptions, timers, and callback edges. No message bytes flow here.ExecutableNode::on_callbackis the body — called by the image’s executor each time a callback fires.stateis your per-node mutable storage.nros::node!(Talker)must be the last public API call in the file. It generates theextern "C"trampolines the entry macro imports.- There is no
fn main()in a Node pkg. ENTITY_BOUNDSis static RAM you either pay or do not (issue 0857).nros::node!emits astaticsized from it, and a publisher slot carries a loan arena — so the default (NROS_RUNTIME_MAX_CELL_ENTITIES, 8 per kind) measured 50,824 bytes of.bssfor a one-service component against 568 bytes for the same component declaring what it creates. Count the five kinds in your ownregister(); a timer or a subscription needs no slot here (they claim an executor callback slot instead). Declaring FEWER thanregister()creates is a registration error at boot, never a silent drop, so the number is safe to make exact.
package.xml — the ROS 2 manifest
A Node pkg that uses generated message types lists them as <exec_depend>
entries. Minimal example:
<?xml version="1.0"?>
<package format="3">
<name>talker_pkg</name>
<version>0.1.0</version>
<description>Talker node</description>
<maintainer email="dev@example.com">Developer</maintainer>
<license>MIT OR Apache-2.0</license>
<depend>std_msgs</depend>
<export>
<build_type>ament_cargo</build_type>
</export>
</package>
If your node uses no external message packages, the <depend> line can be
omitted.
Services & clients
Server (responds to requests)
Declare the service in Component::register and handle requests in the node body.
-
Rust — declare a service edge in
register; requests dispatch intoExecutableNode::on_callbacklike any other callback (read the request and write the reply through theCallbackCtx). -
C++/C (the
configure(Node&)component shape) — bind a typed member withrclcpp::bind_service:// Response on_request(const Request&) — no hand-rolled CDR. AddTwoInts::Response on_add(const AddTwoInts::Request& req) { AddTwoInts::Response r; r.sum = req.a + req.b; return r; } ::rclcpp::Result configure(::rclcpp::Node& node) { return ::rclcpp::bind_service<AddTwoInts, MyServer, &MyServer::on_add>( node, "/add_two_ints", this); // service-type name from AddTwoInts::TYPE_NAME }bind_servicedeserializes the request into the generatedSvc::Request, calls your typed member, and serializes the returnedSvc::Response— no manual byte offsets. (bind_service_raw<&method>is still available for the rare hand-rolled case.)
Client (issues requests) — call from tick, not on_callback
A node cannot make a blocking service/action client call from
on_callback: that runs while the executor is mid-dispatch, so a blocking call
would deadlock the spin. Client calls go on the separate per-spin hook
ExecutableNode::tick(&mut TickCtx), which the executor runs once per
spin_once between callback dispatch:
#![allow(unused)]
fn main() {
fn on_callback(state: &mut State, cb: Callback<'_>, ctx: &mut CallbackCtx<'_>) {
// ...decide a request is needed; just arm a flag / stash args:
state.want_call = true;
}
fn tick(state: &mut State, ctx: &mut TickCtx<'_>) {
if state.want_call {
state.want_call = false;
let reply = ctx.call_for_name("add_two_ints", &request); // blocking, safe here
// ...use reply
}
}
}
The two-surface pattern (arm in on_callback, call in tick) is the supported
way to drive a client from a declarative node. See
examples/workspaces/rust/.../service_client_pkg for the reference.
Building
You never build a Node pkg on its own — you build an image, and it links the Node pkgs its launch file names:
# From examples/workspaces/rust/ (or your workspace root):
nros sync
nros build linux
nros sync writes the generated message crates and the build settings each
image needs; run it once per workspace, and again after editing a .msg.
No per-Node-pkg invocation is needed, and no cross-compile flags either: the
image’s board carries the rustc triple, so building for an embedded target is
another row rather than another command line.
[image.freertos]
board = "mps2-an385-freertos"
nros build freertos
Next steps
- Bringup packages — wire the Node pkgs together into a topology.
- Images — the
[image.*]row that becomes a binary, on real hardware or a host process. - Role reference —
the full reference for the package roles, metadata fields, and the
nros::main!()forms.