Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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"
FieldPurpose
pkgThe package that provides the node (matches <name> in its package.xml)
classFully-qualified path to the type that impls Node + ExecutableNode
nameDefault ROS 2 node name (remappable at launch)
dispatchOptional — 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::register is declarative — it runs once at startup to declare publishers, subscriptions, timers, and callback edges. No message bytes flow here.
  • ExecutableNode::on_callback is the body — called by the image’s executor each time a callback fires. state is your per-node mutable storage.
  • nros::node!(Talker) must be the last public API call in the file. It generates the extern "C" trampolines the entry macro imports.
  • There is no fn main() in a Node pkg.
  • ENTITY_BOUNDS is static RAM you either pay or do not (issue 0857). nros::node! emits a static sized 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 .bss for a one-service component against 568 bytes for the same component declaring what it creates. Count the five kinds in your own register(); a timer or a subscription needs no slot here (they claim an executor callback slot instead). Declaring FEWER than register() 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 into ExecutableNode::on_callback like any other callback (read the request and write the reply through the CallbackCtx).

  • C++/C (the configure(Node&) component shape) — bind a typed member with rclcpp::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_service deserializes the request into the generated Svc::Request, calls your typed member, and serializes the returned Svc::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.